diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 8d137cb5c..c6943ed0e 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -765,7 +765,14 @@ public enum FetchType { } public func fetchUnreadCountForStarredArticles(_ completion: @escaping SingleUnreadCountCompletionBlock) { - database.fetchStarredAndUnreadCount(for: flattenedFeedIDs(), completion: completion) + Task { @MainActor in + do { + let unreadCount = try await database.unreadCountForStarredArticlesForFeedIDs(flattenedFeedIDs()) + completion(.success(unreadCount)) + } catch { + completion(.failure(error as! DatabaseError)) + } + } } public func fetchCountForStarredArticles() throws -> Int { @@ -820,14 +827,14 @@ public enum FetchType { // Used only by an On My Mac or iCloud account. precondition(Thread.isMainThread) precondition(type == .onMyMac || type == .cloudKit) - - database.update(with: parsedItems, feedID: feedID, deleteOlder: deleteOlder) { updateArticlesResult in - switch updateArticlesResult { - case .success(let articleChanges): + + Task { @MainActor in + do { + let articleChanges = try await database.update(with: parsedItems, feedID: feedID, deleteOlder: deleteOlder) self.sendNotificationAbout(articleChanges) completion(.success(articleChanges)) - case .failure(let databaseError): - completion(.failure(databaseError)) + } catch { + completion(.failure(error as! DatabaseError)) } } } @@ -841,13 +848,13 @@ public enum FetchType { return } - database.update(feedIDsAndItems: feedIDsAndItems, defaultRead: defaultRead) { updateArticlesResult in - switch updateArticlesResult { - case .success(let newAndUpdatedArticles): - self.sendNotificationAbout(newAndUpdatedArticles) + Task { @MainActor in + do { + let articleChanges = try await database.update(feedIDsAndItems: feedIDsAndItems, defaultRead: defaultRead) + self.sendNotificationAbout(articleChanges) completion(nil) - case .failure(let databaseError): - completion(databaseError) + } catch { + completion(error as? DatabaseError) } } } @@ -975,13 +982,6 @@ public enum FetchType { } // Delete the articles associated with the given set of articleIDs - func delete(articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { - guard !articleIDs.isEmpty else { - completion?(nil) - return - } - database.delete(articleIDs: articleIDs, completion: completion) - } /// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance. func emptyCaches() { @@ -1501,9 +1501,9 @@ private extension Account { fetchingAllUnreadCounts = true database.fetchAllUnreadCounts { result in Task { @MainActor in - guard let unreadCountDictionary = try? result.get() else { - return - } + guard let unreadCountDictionary = try? result.get() else { + return + } self.processUnreadCounts(unreadCountDictionary: unreadCountDictionary, feeds: self.flattenedFeeds()) self.fetchingAllUnreadCounts = false diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift index 793aa65e9..d501af85f 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift @@ -25,10 +25,13 @@ public typealias SingleUnreadCountResult = Result public typealias SingleUnreadCountCompletionBlock = (SingleUnreadCountResult) -> Void public struct ArticleChanges { + public let newArticles: Set
? public let updatedArticles: Set
? public let deletedArticles: Set
? + static let empty = ArticleChanges(newArticles: nil, updatedArticles: nil, deletedArticles: nil) + public init() { self.newArticles = Set
() self.updatedArticles = Set
() @@ -197,34 +200,28 @@ public typealias ArticleStatusesResultBlock = (ArticleStatusesResult) -> Void try await articlesTable.unreadCountForFeedIDsSince(feedIDs, since) } - public func fetchStarredAndUnreadCount(for feedIDs: Set, completion: @escaping SingleUnreadCountCompletionBlock) { - articlesTable.fetchStarredAndUnreadCount(feedIDs, completion) + public func unreadCountForStarredArticlesForFeedIDs(_ feedIDs: Set) async throws -> Int { + try await articlesTable.unreadCountForStarredArticlesForFeedIDs(feedIDs) } // MARK: - Saving, Updating, and Deleting Articles /// Update articles and save new ones — for feed-based systems (local and iCloud). - public func update(with parsedItems: Set, feedID: String, deleteOlder: Bool, completion: @escaping UpdateArticlesCompletionBlock) { + public func update(with parsedItems: Set, feedID: String, deleteOlder: Bool) async throws -> ArticleChanges { precondition(retentionStyle == .feedBased) - articlesTable.update(parsedItems, feedID, deleteOlder, completion) + return try await articlesTable.update(parsedItems, feedID, deleteOlder) } /// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.). - public func update(feedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) { + public func update(feedIDsAndItems: [String: Set], defaultRead: Bool) async throws -> ArticleChanges { precondition(retentionStyle == .syncSystem) - articlesTable.update(feedIDsAndItems, defaultRead, completion) + return try await articlesTable.update(feedIDsAndItems, defaultRead) } /// Delete articles public func deleteArticleIDs(_ articleIDs: Set) async throws { try await articlesTable.deleteArticleIDs(articleIDs) } - - /// Delete articles - public func delete(articleIDs: Set, completion: DatabaseCompletionBlock?) { - articlesTable.delete(articleIDs: articleIDs, completion: completion) - } - // MARK: - Status /// Fetch the articleIDs of unread articles. diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index 709e3136f..2b6b97bfe 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -203,11 +203,10 @@ final class ArticlesTable: DatabaseTable { // MARK: - Updating and Deleting - func update(_ parsedItems: Set, _ feedID: String, _ deleteOlder: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { + func update(_ parsedItems: Set, _ feedID: String, _ deleteOlder: Bool) async throws -> ArticleChanges { precondition(retentionStyle == .feedBased) if parsedItems.isEmpty { - callUpdateArticlesCompletionBlock(nil, nil, nil, completion) - return + return ArticleChanges.empty } // 1. Ensure statuses for all the incoming articles. @@ -220,74 +219,74 @@ final class ArticlesTable: DatabaseTable { // 8. Delete Articles in database no longer present in the feed. // 9. Update search index. - self.queue.runInTransaction { (databaseResult) in - - func makeDatabaseCalls(_ database: FMDatabase) { - let articleIDs = parsedItems.articleIDs() - - let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 - assert(statusesDictionary.count == articleIDs.count) - - let incomingArticles = Article.articlesWithParsedItems(parsedItems, feedID, self.accountID, statusesDictionary) //2 - if incomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) - return - } - - let fetchedArticles = self.fetchArticlesForFeedID(feedID, database) //4 - let fetchedArticlesDictionary = fetchedArticles.dictionary() - - let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 - let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 - - // Articles to delete are 1) not starred and 2) older than 30 days and 3) no longer in feed. - let articlesToDelete: Set
- if deleteOlder { - let cutoffDate = Date().bySubtracting(days: 30) - articlesToDelete = fetchedArticles.filter { (article) -> Bool in - return !article.status.starred && article.status.dateArrived < cutoffDate && !articleIDs.contains(article.articleID) + return try await withCheckedThrowingContinuation { continuation in + self.queue.runInTransaction { (databaseResult) in + + func updateArticlesInDatabase(_ database: FMDatabase) -> ArticleChanges { + let articleIDs = parsedItems.articleIDs() + + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 + assert(statusesDictionary.count == articleIDs.count) + + let incomingArticles = Article.articlesWithParsedItems(parsedItems, feedID, self.accountID, statusesDictionary) //2 + if incomingArticles.isEmpty { + return ArticleChanges.empty } - } else { - articlesToDelete = Set
() + + let fetchedArticles = self.fetchArticlesForFeedID(feedID, database) //4 + let fetchedArticlesDictionary = fetchedArticles.dictionary() + + let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 + let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 + + // Articles to delete are 1) not starred and 2) older than 30 days and 3) no longer in feed. + let articlesToDelete: Set
+ if deleteOlder { + let cutoffDate = Date().bySubtracting(days: 30) + articlesToDelete = fetchedArticles.filter { (article) -> Bool in + return !article.status.starred && article.status.dateArrived < cutoffDate && !articleIDs.contains(article.articleID) + } + } else { + articlesToDelete = Set
() + } + + self.addArticlesToCache(newArticles) + self.addArticlesToCache(updatedArticles) + + // 8. Delete articles no longer in feed. + let articleIDsToDelete = articlesToDelete.articleIDs() + if !articleIDsToDelete.isEmpty { + self.removeArticles(articleIDsToDelete, database) + self.removeArticleIDsFromCache(articleIDsToDelete) + } + + // 9. Update search index. + if let newArticles = newArticles { + self.searchTable.indexNewArticles(newArticles, database) + } + if let updatedArticles = updatedArticles { + self.searchTable.indexUpdatedArticles(updatedArticles, database) + } + + let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: articlesToDelete) + return articleChanges } - - self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, articlesToDelete, completion) //7 - - self.addArticlesToCache(newArticles) - self.addArticlesToCache(updatedArticles) - - // 8. Delete articles no longer in feed. - let articleIDsToDelete = articlesToDelete.articleIDs() - if !articleIDsToDelete.isEmpty { - self.removeArticles(articleIDsToDelete, database) - self.removeArticleIDsFromCache(articleIDsToDelete) - } - - // 9. Update search index. - if let newArticles = newArticles { - self.searchTable.indexNewArticles(newArticles, database) - } - if let updatedArticles = updatedArticles { - self.searchTable.indexUpdatedArticles(updatedArticles, database) - } - } - - switch databaseResult { - case .success(let database): - makeDatabaseCalls(database) - case .failure(let databaseError): - DispatchQueue.main.async { - completion(.failure(databaseError)) + + switch databaseResult { + case .success(let database): + let articleChanges = updateArticlesInDatabase(database) + continuation.resume(returning: articleChanges) + case .failure(let databaseError): + continuation.resume(throwing: databaseError) } } } } - func update(_ feedIDsAndItems: [String: Set], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { + func update(_ feedIDsAndItems: [String: Set], _ read: Bool) async throws -> ArticleChanges { precondition(retentionStyle == .syncSystem) if feedIDsAndItems.isEmpty { - callUpdateArticlesCompletionBlock(nil, nil, nil, completion) - return + return ArticleChanges.empty } // 1. Ensure statuses for all the incoming articles. @@ -299,56 +298,56 @@ final class ArticlesTable: DatabaseTable { // 7. Call back with new and updated Articles. // 8. Update search index. - self.queue.runInTransaction { (databaseResult) in + return try await withCheckedThrowingContinuation { continuation in + self.queue.runInTransaction { (databaseResult) in - func makeDatabaseCalls(_ database: FMDatabase) { - var articleIDs = Set() - for (_, parsedItems) in feedIDsAndItems { - articleIDs.formUnion(parsedItems.articleIDs()) + func updateArticlesInDatabase(_ database: FMDatabase) -> ArticleChanges { + var articleIDs = Set() + for (_, parsedItems) in feedIDsAndItems { + articleIDs.formUnion(parsedItems.articleIDs()) + } + + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 + assert(statusesDictionary.count == articleIDs.count) + + let allIncomingArticles = Article.articlesWithFeedIDsAndItems(feedIDsAndItems, self.accountID, statusesDictionary) //2 + if allIncomingArticles.isEmpty { + return ArticleChanges.empty + } + + let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3 + if incomingArticles.isEmpty { + return ArticleChanges.empty + } + + let incomingArticleIDs = incomingArticles.articleIDs() + let fetchedArticles = self.fetchArticlesForArticleIDs(incomingArticleIDs, database) //4 + let fetchedArticlesDictionary = fetchedArticles.dictionary() + + let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 + let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 + + self.addArticlesToCache(newArticles) + self.addArticlesToCache(updatedArticles) + + // 8. Update search index. + if let newArticles = newArticles { + self.searchTable.indexNewArticles(newArticles, database) + } + if let updatedArticles = updatedArticles { + self.searchTable.indexUpdatedArticles(updatedArticles, database) + } + + let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: nil) + return articleChanges } - let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 - assert(statusesDictionary.count == articleIDs.count) - - let allIncomingArticles = Article.articlesWithFeedIDsAndItems(feedIDsAndItems, self.accountID, statusesDictionary) //2 - if allIncomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) - return - } - - let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3 - if incomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) - return - } - - let incomingArticleIDs = incomingArticles.articleIDs() - let fetchedArticles = self.fetchArticlesForArticleIDs(incomingArticleIDs, database) //4 - let fetchedArticlesDictionary = fetchedArticles.dictionary() - - let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 - let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 - - self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, nil, completion) //7 - - self.addArticlesToCache(newArticles) - self.addArticlesToCache(updatedArticles) - - // 8. Update search index. - if let newArticles = newArticles { - self.searchTable.indexNewArticles(newArticles, database) - } - if let updatedArticles = updatedArticles { - self.searchTable.indexUpdatedArticles(updatedArticles, database) - } - } - - switch databaseResult { - case .success(let database): - makeDatabaseCalls(database) - case .failure(let databaseError): - DispatchQueue.main.async { - completion(.failure(databaseError)) + switch databaseResult { + case .success(let database): + let articleChanges = updateArticlesInDatabase(database) + continuation.resume(returning: articleChanges) + case .failure(let databaseError): + continuation.resume(throwing: databaseError) } } } @@ -356,44 +355,19 @@ final class ArticlesTable: DatabaseTable { public func deleteArticleIDs(_ articleIDs: Set) async throws { - try await withCheckedThrowingContinuation { continuation in - Task { @MainActor in - queue.runInTransaction { databaseResult in - switch databaseResult { - case .success(let database): - self.removeArticles(articleIDs, database) - continuation.resume() - case .failure(let databaseError): - continuation.resume(throwing: databaseError) - } - } - } - } - } - - public func delete(articleIDs: Set, completion: DatabaseCompletionBlock?) { - self.queue.runInTransaction { (databaseResult) in - - func makeDatabaseCalls(_ database: FMDatabase) { - self.removeArticles(articleIDs, database) - DispatchQueue.main.async { - completion?(nil) + try await withCheckedThrowingContinuation { continuation in + queue.runInTransaction { databaseResult in + switch databaseResult { + case .success(let database): + self.removeArticles(articleIDs, database) + continuation.resume() + case .failure(let databaseError): + continuation.resume(throwing: databaseError) } } - - switch databaseResult { - case .success(let database): - makeDatabaseCalls(database) - case .failure(let databaseError): - DispatchQueue.main.async { - completion?(databaseError) - } - } - } - } - + // MARK: - Unread Counts func unreadCountForFeedID(_ feedID: String) async throws -> Int { @@ -491,6 +465,34 @@ final class ArticlesTable: DatabaseTable { } } + func unreadCountForStarredArticlesForFeedIDs(_ feedIDs: Set) async throws -> Int { + if feedIDs.isEmpty { + return 0 + } + + return try await withCheckedThrowingContinuation { continuation in + queue.runInDatabase { databaseResult in + + func fetchUnreadCount(_ database: FMDatabase) -> Int { + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! + let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and starred=1;" + let parameters = Array(feedIDs) as [Any] + + let unreadCount = self.numberWithSQLAndParameters(sql, parameters, in: database) + return unreadCount + } + + switch databaseResult { + case .success(let database): + let unreadCount = fetchUnreadCount(database) + continuation.resume(returning: unreadCount) + case .failure(let databaseError): + continuation.resume(throwing: databaseError) + } + } + } + } + func fetchStarredAndUnreadCount(_ feedIDs: Set, _ completion: @escaping SingleUnreadCountCompletionBlock) { if feedIDs.isEmpty { completion(.success(0)) @@ -1073,15 +1075,6 @@ private extension ArticlesTable { return articles.filter{ articleIDs.contains($0.articleID) } } - // MARK: - Saving Parsed Items - - func callUpdateArticlesCompletionBlock(_ newArticles: Set
?, _ updatedArticles: Set
?, _ deletedArticles: Set
?, _ completion: @escaping UpdateArticlesCompletionBlock) { - let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: deletedArticles) - DispatchQueue.main.async { - completion(.success(articleChanges)) - } - } - // MARK: - Saving New Articles func findNewArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article]) -> Set
? {