Run database fetches async, in the timeline, when appropriate — for instance, when All Unread is selected and new articles come in.

This commit is contained in:
Brent Simmons 2019-07-05 20:06:31 -07:00
parent 6f16a2715e
commit 7a204ad6ed
20 changed files with 715 additions and 378 deletions

View File

@ -36,6 +36,16 @@ public enum AccountType: Int {
// TODO: more
}
public enum FetchType {
case starred
case unread
case today
case unreadForFolder(Folder)
case feed(Feed)
case articleIDs(Set<String>)
case search(String)
}
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
public struct UserInfoKey {
@ -471,85 +481,44 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
public func fetchArticles(forArticleIDs articleIDs: Set<String>) -> Set<Article> {
return database.fetchArticles(forArticleIDs: articleIDs)
}
public func fetchArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(for: feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(for: Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
public func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
public func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(for: feeds.feedIDs())
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
articles.forEach { (article) in
precondition(!article.status.read)
unreadCountStorage[article.feedID, default: 0] += 1
public func fetchArticles(_ fetchType: FetchType) -> Set<Article> {
switch fetchType {
case .starred:
return fetchStarredArticles()
case .unread:
return fetchUnreadArticles()
case .today:
return fetchTodayArticles()
case .unreadForFolder(let folder):
return fetchArticles(folder: folder)
case .feed(let feed):
return fetchArticles(feed: feed)
case .articleIDs(let articleIDs):
return fetchArticles(articleIDs: articleIDs)
case .search(let searchString):
return fetchArticlesMatching(searchString)
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
}
public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) {
switch fetchType {
case .starred:
fetchStarredArticlesAsync(callback)
case .unread:
fetchUnreadArticlesAsync(callback)
case .today:
fetchTodayArticlesAsync(callback)
case .unreadForFolder(let folder):
fetchArticlesAsync(folder: folder, callback)
case .feed(let feed):
fetchArticlesAsync(feed: feed, callback)
case .articleIDs(let articleIDs):
fetchArticlesAsync(articleIDs: articleIDs, callback)
case .search(let searchString):
fetchArticlesMatchingAsync(searchString, callback)
}
return articles
}
public func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(for: flattenedFeeds().feedIDs())
}
public func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(for: flattenedFeeds().feedIDs())
}
public func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, for: flattenedFeeds().feedIDs())
}
private func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
}
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
@ -816,6 +785,133 @@ extension Account: FeedMetadataDelegate {
}
}
// MARK: - Fetching (Private)
private extension Account {
func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(flattenedFeeds().feedIDs())
}
func fetchStarredArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchedStarredArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: self, callback)
}
func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(flattenedFeeds().feedIDs())
}
func fetchTodayArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchTodayArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
func fetchArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: folder, callback)
}
func fetchArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
func fetchArticlesAsync(feed: Feed, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesAsync(feed.feedID) { [weak self] (articles) in
self?.validateUnreadCount(feed, articles)
callback(articles)
}
}
func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs())
}
func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return database.fetchArticles(articleIDs: articleIDs)
}
func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
return database.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
func fetchUnreadArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
func fetchUnreadArticlesAsync(for feed: Feed, callback: @escaping (Set<Article>) -> Void) {
// database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in
// self?.validateUnreadCount(feed, articles)
// callback(articles)
// }
}
func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(feeds.feedIDs())
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
return articles
}
func fetchUnreadArticlesAsync(forContainer container: Container, _ callback: @escaping ArticleSetBlock) {
let feeds = container.flattenedFeeds()
database.fetchUnreadArticlesAsync(feeds.feedIDs()) { [weak self] (articles) in
self?.validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
callback(articles)
}
}
func validateUnreadCountsAfterFetchingUnreadArticles(_ feeds: Set<Feed>, _ articles: Set<Article>) {
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
articles.forEach { (article) in
precondition(!article.status.read)
unreadCountStorage[article.feedID, default: 0] += 1
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
}
}
func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
}
}
// MARK: - Disk (Private)
private extension Account {

View File

@ -202,7 +202,39 @@ public final class AccountManager: UnreadCountProvider {
unreadCount = calculateUnreadCount(activeAccounts)
}
// MARK: - Fetching Articles
// These fetch articles from active accounts and return a merged Set<Article>.
public func fetchArticles(_ fetchType: FetchType) -> Set<Article> {
precondition(Thread.isMainThread)
var articles = Set<Article>()
for account in activeAccounts {
articles.formUnion(account.fetchArticles(fetchType))
}
return articles
}
public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) {
precondition(Thread.isMainThread)
var allFetchedArticles = Set<Article>()
let numberOfAccounts = activeAccounts.count
var accountsReporting = 0
for account in activeAccounts {
account.fetchArticlesAsync(fetchType) { (articles) in
allFetchedArticles.formUnion(articles)
accountsReporting += 1
if accountsReporting == numberOfAccounts {
callback(allFetchedArticles)
}
}
}
}
// MARK: Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) {

View File

@ -12,44 +12,59 @@ import Articles
public protocol ArticleFetcher {
func fetchArticles() -> Set<Article>
func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock)
func fetchUnreadArticles() -> Set<Article>
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock)
}
extension Feed: ArticleFetcher {
public func fetchArticles() -> Set<Article> {
return account?.fetchArticles(.feed(self)) ?? Set<Article>()
}
public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
guard let account = account else {
assertionFailure("Expected feed.account, but got nil.")
return Set<Article>()
callback(Set<Article>())
return
}
return account.fetchArticles(for: self)
account.fetchArticlesAsync(.feed(self), callback)
}
public func fetchUnreadArticles() -> Set<Article> {
preconditionFailure("feed.fetchUnreadArticles is unused.")
}
guard let account = account else {
assertionFailure("Expected feed.account, but got nil.")
return Set<Article>()
}
return account.fetchUnreadArticles(for: self)
public func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
preconditionFailure("feed.fetchUnreadArticlesAsync is unused.")
}
}
extension Folder: ArticleFetcher {
public func fetchArticles() -> Set<Article> {
return fetchUnreadArticles()
}
public func fetchUnreadArticles() -> Set<Article> {
public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(callback)
}
public func fetchUnreadArticles() -> Set<Article> {
guard let account = account else {
assertionFailure("Expected folder.account, but got nil.")
return Set<Article>()
}
return account.fetchArticles(.unreadForFolder(self))
}
return account.fetchArticles(folder: self)
public func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
guard let account = account else {
assertionFailure("Expected folder.account, but got nil.")
callback(Set<Article>())
return
}
account.fetchArticlesAsync(.unreadForFolder(self), callback)
}
}

View File

@ -1131,7 +1131,7 @@ private extension FeedbinAccountDelegate {
// Mark articles as unread
let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs)
let markUnreadArticles = account.fetchArticles(.articleIDs(deltaUnreadArticleIDs))
DispatchQueue.main.async {
_ = account.update(markUnreadArticles, statusKey: .read, flag: false)
}
@ -1147,7 +1147,7 @@ private extension FeedbinAccountDelegate {
// Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs)
let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs)
let markReadArticles = account.fetchArticles(.articleIDs(deltaReadArticleIDs))
DispatchQueue.main.async {
_ = account.update(markReadArticles, statusKey: .read, flag: true)
}
@ -1174,7 +1174,7 @@ private extension FeedbinAccountDelegate {
// Mark articles as starred
let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs)
let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs)
let markStarredArticles = account.fetchArticles(.articleIDs(deltaStarredArticleIDs))
DispatchQueue.main.async {
_ = account.update(markStarredArticles, statusKey: .starred, flag: true)
}
@ -1190,7 +1190,7 @@ private extension FeedbinAccountDelegate {
// Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs)
let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs)
let markUnstarredArticles = account.fetchArticles(.articleIDs(deltaUnstarredArticleIDs))
DispatchQueue.main.async {
_ = account.update(markUnstarredArticles, statusKey: .starred, flag: false)
}

View File

@ -8,6 +8,8 @@
import Foundation
public typealias ArticleSetBlock = (Set<Article>) -> Void
public struct Article: Hashable {
public let articleID: String // Unique database ID (possibly sync service ID)

View File

@ -12,10 +12,12 @@ import RSDatabase
import RSParser
import Articles
// This file and UnreadCountDictionary are the entirety of the public API for Database.framework.
// This file is the entirety of the public API for ArticlesDatabase.framework.
// Everything else is implementation.
public typealias ArticleResultBlock = (Set<Article>) -> Void
// Main thread only.
public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount
public typealias UnreadCountCompletionBlock = (UnreadCountDictionary) -> Void
public typealias UpdateArticlesWithFeedCompletionBlock = (Set<Article>?, Set<Article>?) -> Void //newArticles, updatedArticles
@ -46,38 +48,60 @@ public final class ArticlesDatabase {
// MARK: - Fetching Articles
public func fetchArticles(for feedID: String) -> Set<Article> {
public func fetchArticles(_ feedID: String) -> Set<Article> {
return articlesTable.fetchArticles(feedID)
}
public func fetchArticles(forArticleIDs articleIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticles(forArticleIDs: articleIDs)
public func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticles(articleIDs: articleIDs)
}
public func fetchArticlesAsync(for feedID: String, _ resultBlock: @escaping ArticleResultBlock) {
articlesTable.fetchArticlesAsync(feedID, withLimits: true, resultBlock)
public func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchUnreadArticles(feedIDs)
}
public func fetchUnreadArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchUnreadArticles(for: feedIDs)
public func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchTodayArticles(feedIDs)
}
public func fetchTodayArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchTodayArticles(for: feedIDs)
public func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchStarredArticles(feedIDs)
}
public func fetchStarredArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchStarredArticles(for: feedIDs)
public func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticlesMatching(searchString, feedIDs)
}
public func fetchArticlesMatching(_ searchString: String, for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticlesMatching(searchString, for: feedIDs)
// MARK: - Fetching Articles Async
public func fetchArticlesAsync(_ feedID: String, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesAsync(feedID, callback)
}
public func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
public func fetchUnreadArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchUnreadArticlesAsync(feedIDs, callback)
}
public func fetchTodayArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchTodayArticlesAsync(feedIDs, callback)
}
public func fetchedStarredArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchStarredArticlesAsync(feedIDs, callback)
}
public func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesMatchingAsync(searchString, feedIDs, callback)
}
// MARK: - Unread Counts
public func fetchUnreadCounts(for feedIDs: Set<String>, _ completion: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchUnreadCounts(feedIDs, completion)
public func fetchUnreadCounts(for feedIDs: Set<String>, _ callback: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchUnreadCounts(feedIDs, callback)
}
public func fetchUnreadCount(for feedIDs: Set<String>, since: Date, callback: @escaping (Int) -> Void) {
@ -88,8 +112,8 @@ public final class ArticlesDatabase {
articlesTable.fetchStarredAndUnreadCount(feedIDs, callback)
}
public func fetchAllNonZeroUnreadCounts(_ completion: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchAllUnreadCounts(completion)
public func fetchAllNonZeroUnreadCounts(_ callback: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchAllUnreadCounts(callback)
}
// MARK: - Saving and Updating Articles

View File

@ -23,7 +23,6 @@
8455807A1F0AF67D003CCFA1 /* ArticleStatus+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580791F0AF67D003CCFA1 /* ArticleStatus+Database.swift */; };
8455807C1F0C0DBD003CCFA1 /* Attachment+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */; };
8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */; };
848AD2961F58A91E004FB0EC /* UnreadCountDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848AD2951F58A91E004FB0EC /* UnreadCountDictionary.swift */; };
848E3EB920FBCFD20004B7ED /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848E3EB820FBCFD20004B7ED /* RSCore.framework */; };
848E3EBD20FBCFDE0004B7ED /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848E3EBC20FBCFDE0004B7ED /* RSDatabase.framework */; };
84E156EA1F0AB80500F8CC05 /* ArticlesDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156E91F0AB80500F8CC05 /* ArticlesDatabase.swift */; };
@ -131,7 +130,6 @@
8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Attachment+Database.swift"; path = "Extensions/Attachment+Database.swift"; sourceTree = "<group>"; };
8461461E1F0ABC7300870CB3 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
8477ACBB2221E76F00DF7F37 /* SearchTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTable.swift; sourceTree = "<group>"; };
848AD2951F58A91E004FB0EC /* UnreadCountDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadCountDictionary.swift; sourceTree = "<group>"; };
848E3EB820FBCFD20004B7ED /* RSCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
848E3EBA20FBCFD80004B7ED /* RSParser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSParser.framework; sourceTree = BUILT_PRODUCTS_DIR; };
848E3EBC20FBCFDE0004B7ED /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -178,7 +176,6 @@
isa = PBXGroup;
children = (
84E156E91F0AB80500F8CC05 /* ArticlesDatabase.swift */,
848AD2951F58A91E004FB0EC /* UnreadCountDictionary.swift */,
845580661F0AEBCD003CCFA1 /* Constants.swift */,
84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */,
8477ACBB2221E76F00DF7F37 /* SearchTable.swift */,
@ -356,13 +353,13 @@
TargetAttributes = {
844BEE361F0AB3AA004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = 9C84TZ7Q6Z;
LastSwiftMigration = 0830;
ProvisioningStyle = Automatic;
};
844BEE3F1F0AB3AB004AB7CD = {
CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = 9C84TZ7Q6Z;
ProvisioningStyle = Automatic;
};
};
@ -501,7 +498,6 @@
files = (
845580671F0AEBCD003CCFA1 /* Constants.swift in Sources */,
843CB9961F34174100EE6581 /* Author+Database.swift in Sources */,
848AD2961F58A91E004FB0EC /* UnreadCountDictionary.swift in Sources */,
845580761F0AF670003CCFA1 /* Article+Database.swift in Sources */,
8455807A1F0AF67D003CCFA1 /* ArticleStatus+Database.swift in Sources */,
8455807C1F0C0DBD003CCFA1 /* Attachment+Database.swift in Sources */,

View File

@ -29,6 +29,8 @@ final class ArticlesTable: DatabaseTable {
private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)!
private var maximumArticleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 4 * 31)!
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
init(name: String, accountID: String, queue: RSDatabaseQueue) {
self.name = name
@ -43,52 +45,109 @@ final class ArticlesTable: DatabaseTable {
self.attachmentsLookupTable = DatabaseLookupTable(name: DatabaseTableName.attachmentsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.attachmentID, relatedTable: attachmentsTable, relationshipName: RelationshipName.attachments)
}
// MARK: Fetching
// MARK: - Fetch Articles for Feed
func fetchArticles(_ feedID: String) -> Set<Article> {
var articles = Set<Article>()
return fetchArticles{ self.fetchArticlesForFeedID(feedID, withLimits: true, $0) }
}
queue.fetchSync { (database) in
articles = self.fetchArticlesForFeedID(feedID, withLimits: true, database: database)
func fetchArticlesAsync(_ feedID: String, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchArticlesForFeedID(feedID, withLimits: true, $0) }, callback)
}
private func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, _ database: FMDatabase) -> Set<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits)
}
// MARK: - Fetch Articles by articleID
func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchArticles(articleIDs: articleIDs, $0) }
}
func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
return fetchArticlesAsync({ self.fetchArticles(articleIDs: articleIDs, $0) }, callback)
}
private func fetchArticles(articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
}
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let whereClause = "articleID in \(placeholders)"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
// MARK: - Fetch Unread Articles
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchUnreadArticles(feedIDs, $0) }
}
func fetchUnreadArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchUnreadArticles(feedIDs, $0) }, callback)
}
private func fetchUnreadArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
if feedIDs.isEmpty {
return Set<Article>()
}
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and read=0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
}
// MARK: - Fetch Today Articles
func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchTodayArticles(feedIDs, $0) }
}
func fetchTodayArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchTodayArticles(feedIDs, $0) }, callback)
}
private func fetchTodayArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?)
//
// datePublished may be nil, so we fall back to dateArrived.
if feedIDs.isEmpty {
return Set<Article>()
}
let startOfToday = NSCalendar.startOfToday()
let parameters = feedIDs.map { $0 as AnyObject } + [startOfToday as AnyObject, startOfToday as AnyObject]
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and userDeleted = 0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
// MARK: - Fetch Starred Articles
func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchStarredArticles(feedIDs, $0) }
}
func fetchStarredArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchStarredArticles(feedIDs, $0) }, callback)
}
private func fetchStarredArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred = 1 and userDeleted = 0;
if feedIDs.isEmpty {
return Set<Article>()
}
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and starred = 1 and userDeleted = 0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
// MARK: - Fetch Search Articles
public func fetchArticles(forArticleIDs articleIDs: Set<String>) -> Set<Article> {
return fetchArticlesForIDs(articleIDs)
}
func fetchArticlesAsync(_ feedID: String, withLimits: Bool, _ resultBlock: @escaping ArticleResultBlock) {
queue.fetch { (database) in
let articles = self.fetchArticlesForFeedID(feedID, withLimits: withLimits, database: database)
DispatchQueue.main.async {
resultBlock(articles)
}
}
}
func fetchUnreadArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchUnreadArticles(feedIDs)
}
public func fetchTodayArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchTodayArticles(feedIDs)
}
public func fetchStarredArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchStarredArticles(feedIDs)
}
func fetchArticlesMatching(_ searchString: String, for feedIDs: Set<String>) -> Set<Article> {
func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>) -> Set<Article> {
var articles: Set<Article> = Set<Article>()
queue.fetchSync { (database) in
articles = self.fetchArticlesMatching(searchString, database)
@ -97,6 +156,32 @@ final class ArticlesTable: DatabaseTable {
return articles
}
func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchArticlesMatching(searchString, feedIDs, $0) }, callback)
}
private func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
let sql = "select rowid from search where search match ?;"
let sqlSearchString = sqliteSearchString(with: searchString)
let searchStringParameters = [sqlSearchString]
guard let resultSet = database.executeQuery(sql, withArgumentsIn: searchStringParameters) else {
return Set<Article>()
}
let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) }
if searchRowIDs.isEmpty {
return Set<Article>()
}
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
let whereClause = "searchRowID in \(placeholders)"
let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject]
let articles = fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
// TODO: include the feedIDs in the SQL rather than filtering here.
return articles.filter{ feedIDs.contains($0.feedID) }
}
// MARK: - Fetch Articles for Indexer
func fetchArticleSearchInfos(_ articleIDs: Set<String>, in database: FMDatabase) -> Set<ArticleSearchInfo>? {
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
@ -159,7 +244,7 @@ final class ArticlesTable: DatabaseTable {
return
}
let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database: database) //4
let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
@ -346,6 +431,23 @@ private extension ArticlesTable {
// MARK: Fetching
private func fetchArticles(_ fetchMethod: @escaping ArticlesFetchMethod) -> Set<Article> {
var articles = Set<Article>()
queue.fetchSync { (database) in
articles = fetchMethod(database)
}
return articles
}
private func fetchArticlesAsync(_ fetchMethod: @escaping ArticlesFetchMethod, _ callback: @escaping ArticleSetBlock) {
queue.fetch { (database) in
let articles = fetchMethod(database)
DispatchQueue.main.async {
callback(articles)
}
}
}
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
// 1. Create DatabaseArticles without related objects.
@ -453,97 +555,6 @@ private extension ArticlesTable {
return numberWithSQLAndParameters(sql, [feedID, articleCutoffDate], in: database)
}
func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits)
}
func fetchArticlesForIDs(_ articleIDs: Set<String>) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let whereClause = "articleID in \(placeholders)"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and read=0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
}
return articles
}
func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?)
//
// datePublished may be nil, so we fall back to dateArrived.
let startOfToday = NSCalendar.startOfToday()
let parameters = feedIDs.map { $0 as AnyObject } + [startOfToday as AnyObject, startOfToday as AnyObject]
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and userDeleted = 0"
// let whereClause = "feedID in \(placeholders) and datePublished > ? and userDeleted = 0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred = 1 and userDeleted = 0;
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and starred = 1 and userDeleted = 0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
let sql = "select rowid from search where search match ?;"
let sqlSearchString = sqliteSearchString(with: searchString)

View File

@ -1,28 +0,0 @@
//
// UnreadCountDictionary.swift
// Database
//
// Created by Brent Simmons on 8/31/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import Articles
public struct UnreadCountDictionary {
private var dictionary = [String: Int]()
public var isEmpty: Bool {
return dictionary.count < 1
}
public subscript(_ feedID: String) -> Int? {
get {
return dictionary[feedID]
}
set {
dictionary[feedID] = newValue
}
}
}

View File

@ -0,0 +1,72 @@
//
// FetchRequestOperation.swift
// NetNewsWire
//
// Created by Brent Simmons on 6/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import RSCore
import Account
import Articles
// Main thread only.
// Runs an asynchronous fetch.
typealias FetchRequestOperationResultBlock = (Set<Article>, FetchRequestOperation) -> Void
class FetchRequestOperation {
let id: Int
let resultBlock: FetchRequestOperationResultBlock
var isCanceled = false
var isFinished = false
private let representedObjects: [Any]
init(id: Int, representedObjects: [Any], resultBlock: @escaping FetchRequestOperationResultBlock) {
precondition(Thread.isMainThread)
self.id = id
self.representedObjects = representedObjects
self.resultBlock = resultBlock
}
func run(_ completion: @escaping (FetchRequestOperation) -> Void) {
precondition(Thread.isMainThread)
precondition(!isFinished)
if isCanceled {
completion(self)
return
}
let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher }
if articleFetchers.isEmpty {
isFinished = true
resultBlock(Set<Article>(), self)
completion(self)
return
}
let numberOfFetchers = articleFetchers.count
var fetchersReturned = 0
var fetchedArticles = Set<Article>()
for articleFetcher in articleFetchers {
articleFetcher.fetchArticlesAsync { (articles) in
precondition(Thread.isMainThread)
if self.isCanceled {
completion(self)
return
}
fetchedArticles.formUnion(articles)
fetchersReturned += 1
if fetchersReturned == numberOfFetchers {
self.isFinished = true
self.resultBlock(fetchedArticles, self)
completion(self)
}
}
}
}
}

View File

@ -0,0 +1,54 @@
//
// FetchRequestQueue.swift
// NetNewsWire
//
// Created by Brent Simmons on 6/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
// Main thread only.
class FetchRequestQueue {
private var pendingRequests = [FetchRequestOperation]()
private var currentRequest: FetchRequestOperation? = nil
func cancelAllRequests() {
precondition(Thread.isMainThread)
pendingRequests.forEach { $0.isCanceled = true }
currentRequest?.isCanceled = true
pendingRequests = [FetchRequestOperation]()
}
func add(_ fetchRequestOperation: FetchRequestOperation) {
precondition(Thread.isMainThread)
pendingRequests.append(fetchRequestOperation)
runNextRequestIfNeeded()
}
}
private extension FetchRequestQueue {
func runNextRequestIfNeeded() {
precondition(Thread.isMainThread)
removeCanceledAndFinishedRequests()
guard currentRequest == nil, let requestToRun = pendingRequests.first else {
return
}
currentRequest = requestToRun
pendingRequests.removeFirst()
requestToRun.run { (fetchRequestOperation) in
precondition(fetchRequestOperation === self.currentRequest)
precondition(fetchRequestOperation === requestToRun)
self.currentRequest = nil
self.runNextRequestIfNeeded()
}
}
func removeCanceledAndFinishedRequests() {
pendingRequests = pendingRequests.filter{ !$0.isCanceled && !$0.isFinished }
}
}

View File

@ -33,8 +33,10 @@ final class TimelineContainerViewController: NSViewController {
private lazy var regularTimelineViewController = {
return TimelineViewController(delegate: self)
}()
private lazy var searchTimelineViewController = {
return TimelineViewController(delegate: self)
private lazy var searchTimelineViewController: TimelineViewController = {
let viewController = TimelineViewController(delegate: self)
viewController.showsSearchResults = true
return viewController
}()
override func viewDidLoad() {

View File

@ -40,9 +40,14 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
}
selectionDidChange(nil)
fetchArticles()
if articles.count > 0 {
tableView.scrollRowToVisible(0)
if showsSearchResults {
fetchAndReplaceArticlesAsync()
}
else {
fetchAndReplaceArticlesSync()
if articles.count > 0 {
tableView.scrollRowToVisible(0)
}
}
}
}
@ -50,7 +55,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
private weak var delegate: TimelineDelegate?
var sharingServiceDelegate: NSSharingServiceDelegate?
var showsSearchResults = false
var selectedArticles: [Article] {
return Array(articles.articlesForIndexes(tableView.selectedRowIndexes))
}
@ -79,6 +85,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
}
var undoableCommands = [UndoableCommand]()
private var fetchSerialNumber = 0
private let fetchRequestQueue = FetchRequestQueue()
private var articleRowMap = [String: Int]() // articleID: rowIndex
private var cellAppearance: TimelineCellAppearance!
private var cellAppearanceWithAvatar: TimelineCellAppearance!
@ -100,7 +108,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
}
private var didRegisterForNotifications = false
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 2.0, maxInterval: 5.0)
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0)
private var sortDirection = AppDefaults.timelineSortDirection {
didSet {
@ -502,13 +510,13 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
@objc func accountStateDidChange(_ note: Notification) {
if representedObjectsContainsAnyPseudoFeed() {
fetchArticles()
fetchAndReplaceArticlesAsync()
}
}
@objc func accountsDidChange(_ note: Notification) {
if representedObjectsContainsAnyPseudoFeed() {
fetchArticles()
fetchAndReplaceArticlesAsync()
}
}
@ -521,7 +529,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
@objc func calendarDayChanged(_ note: Notification) {
if representedObjectsContainsTodayFeed() {
DispatchQueue.main.async { [weak self] in
self?.fetchArticles()
self?.fetchAndReplaceArticlesAsync()
}
}
}
@ -606,24 +614,25 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
}
@objc func fetchAndMergeArticles() {
guard let representedObjects = representedObjects else {
return
}
performBlockAndRestoreSelection {
var unsortedArticles = fetchUnsortedArticles(for: representedObjects)
fetchUnsortedArticlesAsync(for: representedObjects) { [weak self] (unsortedArticles) in
// Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles.
guard let strongSelf = self else {
return
}
let unsortedArticleIDs = unsortedArticles.articleIDs()
for article in articles {
var updatedArticles = unsortedArticles
for article in strongSelf.articles {
if !unsortedArticleIDs.contains(article.articleID) {
unsortedArticles.insert(article)
updatedArticles.insert(article)
}
}
updateArticles(with: unsortedArticles)
strongSelf.performBlockAndRestoreSelection {
strongSelf.replaceArticles(with: updatedArticles)
}
}
}
}
@ -842,7 +851,6 @@ private extension TimelineViewController {
}
func emptyTheTimeline() {
if !articles.isEmpty {
articles = [Article]()
}
@ -852,7 +860,7 @@ private extension TimelineViewController {
performBlockAndRestoreSelection {
let unsortedArticles = Set(articles)
updateArticles(with: unsortedArticles)
replaceArticles(with: unsortedArticles)
}
}
@ -919,18 +927,39 @@ private extension TimelineViewController {
// MARK: Fetching Articles
func fetchArticles() {
func fetchAndReplaceArticlesSync() {
// To be called when the user has made a change of selection in the sidebar.
// It blocks the main thread, so that theres no async delay,
// so that the entire display refreshes at once.
// Its a better user experience this way.
cancelPendingAsyncFetches()
guard let representedObjects = representedObjects else {
emptyTheTimeline()
return
}
let fetchedArticles = fetchUnsortedArticles(for: representedObjects)
updateArticles(with: fetchedArticles)
let fetchedArticles = fetchUnsortedArticlesSync(for: representedObjects)
replaceArticles(with: fetchedArticles)
}
func updateArticles(with unsortedArticles: Set<Article>) {
func fetchAndReplaceArticlesAsync() {
// To be called when we need to do an entire fetch, but an async delay is okay.
// Example: we have the Today feed selected, and the calendar day just changed.
cancelPendingAsyncFetches()
guard let representedObjects = representedObjects else {
emptyTheTimeline()
return
}
fetchUnsortedArticlesAsync(for: representedObjects) { [weak self] (articles) in
self?.replaceArticles(with: articles)
}
}
func cancelPendingAsyncFetches() {
fetchSerialNumber += 1
fetchRequestQueue.cancelAllRequests()
}
func replaceArticles(with unsortedArticles: Set<Article>) {
let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection)
if articles != sortedArticles {
@ -938,20 +967,35 @@ private extension TimelineViewController {
}
}
func fetchUnsortedArticles(for representedObjects: [Any]) -> Set<Article> {
var fetchedArticles = Set<Article>()
for object in representedObjects {
if let articleFetcher = object as? ArticleFetcher {
fetchedArticles.formUnion(articleFetcher.fetchArticles())
}
func fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set<Article> {
cancelPendingAsyncFetches()
let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher }
if articleFetchers.isEmpty {
return Set<Article>()
}
var fetchedArticles = Set<Article>()
for articleFetcher in articleFetchers {
fetchedArticles.formUnion(articleFetcher.fetchArticles())
}
return fetchedArticles
}
func fetchUnsortedArticlesAsync(for representedObjects: [Any], callback: @escaping ArticleSetBlock) {
// The callback will *not* be called if the fetch is no longer relevant that is,
// if its been superseded by a newer fetch, or the timeline was emptied, etc., it wont get called.
precondition(Thread.isMainThread)
cancelPendingAsyncFetches()
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, representedObjects: representedObjects) { [weak self] (articles, operation) in
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
}
callback(articles)
}
fetchRequestQueue.add(fetchOperation)
}
func selectArticles(_ articleIDs: [String]) {
let indexesToSelect = indexesForArticleIDs(Set(articleIDs))

View File

@ -267,8 +267,14 @@
84C9FC9D2262A1A900D921D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC9B2262A1A900D921D6 /* Assets.xcassets */; };
84C9FCA12262A1B300D921D6 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC9F2262A1B300D921D6 /* Main.storyboard */; };
84C9FCA42262A1B800D921D6 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FCA22262A1B800D921D6 /* LaunchScreen.storyboard */; };
84CAFCA422BC8C08007694F0 /* FetchRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */; };
84CAFCA522BC8C08007694F0 /* FetchRequestQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */; };
84CAFCAF22BC8C35007694F0 /* FetchRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */; };
84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */; };
84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CC88171FE59CBF00644329 /* SmartFeedsController.swift */; };
84D52E951FE588BB00D14F5B /* DetailStatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */; };
84DEE56522C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */; };
84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */; };
84E185B3203B74E500F69BFA /* SingleLineTextFieldSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */; };
84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */; };
84E46C7D1F75EF7B005ECFB3 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */; };
@ -871,9 +877,12 @@
84C9FC9C2262A1A900D921D6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
84C9FCA02262A1B300D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
84C9FCA32262A1B800D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestQueue.swift; sourceTree = "<group>"; };
84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestOperation.swift; sourceTree = "<group>"; };
84CBDDAE1FD3674C005A61AA /* Technotes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Technotes; sourceTree = "<group>"; };
84CC88171FE59CBF00644329 /* SmartFeedsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedsController.swift; sourceTree = "<group>"; };
84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStatusBarView.swift; sourceTree = "<group>"; };
84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedDelegate.swift; sourceTree = "<group>"; };
84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = "<group>"; };
84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = "<group>"; };
84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = "<group>"; };
@ -1388,6 +1397,8 @@
84E8E0DA202EC49300562D8F /* TimelineViewController+ContextualMenus.swift */,
849A97691ED9EBC8007D329B /* TimelineTableRowView.swift */,
849A976A1ED9EBC8007D329B /* TimelineTableView.swift */,
84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */,
84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */,
844B5B6C1FEA282400C7C76A /* Keyboard */,
84E95D231FB1087500552D99 /* ArticlePasteboardWriter.swift */,
849A976F1ED9EC04007D329B /* Cell */,
@ -1724,6 +1735,7 @@
84F2D5351FC22FCB00998D64 /* PseudoFeed.swift */,
84F2D5391FC2308B00998D64 /* UnreadFeed.swift */,
845EE7C01FC2488C00854A1F /* SmartFeed.swift */,
84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */,
84F2D5361FC22FCB00998D64 /* TodayFeedDelegate.swift */,
845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */,
8477ACBD22238E9500DF7F37 /* SearchFeedDelegate.swift */,
@ -1942,12 +1954,12 @@
ORGANIZATIONNAME = "Ranchero Software";
TargetAttributes = {
6581C73220CED60000F4AD34 = {
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = M8L2WTLA8W;
ProvisioningStyle = Manual;
};
840D617B2029031C009BC708 = {
CreatedOnToolsVersion = 9.3;
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = M8L2WTLA8W;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.BackgroundModes = {
@ -1963,7 +1975,7 @@
};
849C645F1ED37A5D003D8FC0 = {
CreatedOnToolsVersion = 8.2.1;
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = M8L2WTLA8W;
ProvisioningStyle = Manual;
SystemCapabilities = {
com.apple.HardenedRuntime = {
@ -1973,7 +1985,7 @@
};
849C64701ED37A5D003D8FC0 = {
CreatedOnToolsVersion = 8.2.1;
DevelopmentTeam = SHJK2V3AJG;
DevelopmentTeam = 9C84TZ7Q6Z;
ProvisioningStyle = Automatic;
TestTargetID = 849C645F1ED37A5D003D8FC0;
};
@ -2335,6 +2347,7 @@
51C452852265093600C03939 /* AddFeedFolderPickerData.swift in Sources */,
51C4526B226508F600C03939 /* MasterFeedViewController.swift in Sources */,
5126EE97226CB48A00C22AFC /* NavigationStateController.swift in Sources */,
84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */,
51EF0F77227716200050506E /* FaviconGenerator.swift in Sources */,
51C4525A226508D600C03939 /* UIStoryboard-Extensions.swift in Sources */,
5183CCEF227125970010922C /* SettingsViewController.swift in Sources */,
@ -2368,6 +2381,7 @@
515436882291D75D005E1CDF /* AddLocalAccountViewController.swift in Sources */,
51C452AF2265108300C03939 /* ArticleArray.swift in Sources */,
51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */,
84CAFCA522BC8C08007694F0 /* FetchRequestQueue.swift in Sources */,
51C4529C22650A1000C03939 /* SingleFaviconDownloader.swift in Sources */,
51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */,
51C45290226509C100C03939 /* PseudoFeed.swift in Sources */,
@ -2382,6 +2396,7 @@
5183CCE9226F68D90010922C /* AccountRefreshTimer.swift in Sources */,
51C452882265093600C03939 /* AddFeedViewController.swift in Sources */,
51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */,
84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */,
5183CCE3226F314C0010922C /* ProgressTableViewController.swift in Sources */,
512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */,
51C45268226508F600C03939 /* MasterFeedUnreadCountView.swift in Sources */,
@ -2483,6 +2498,7 @@
849A97831ED9EC63007D329B /* SidebarStatusBarView.swift in Sources */,
84F2D5381FC22FCC00998D64 /* TodayFeedDelegate.swift in Sources */,
841ABA5E20145E9200980E11 /* FolderInspectorViewController.swift in Sources */,
84DEE56522C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */,
845213231FCA5B11003B6E93 /* ImageDownloader.swift in Sources */,
51EF0F922279CA620050506E /* AccountsAddTableCellView.swift in Sources */,
849A97431ED9EAA9007D329B /* AddFolderWindowController.swift in Sources */,
@ -2493,6 +2509,7 @@
849A97801ED9EC42007D329B /* DetailViewController.swift in Sources */,
84C9FC6722629B9000D921D6 /* AppDelegate.swift in Sources */,
84C9FC7A22629E1200D921D6 /* AccountsTableViewBackgroundView.swift in Sources */,
84CAFCAF22BC8C35007694F0 /* FetchRequestOperation.swift in Sources */,
8426119E1FCB6ED40086A189 /* HTMLMetadataDownloader.swift in Sources */,
849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */,
5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */,
@ -2514,6 +2531,7 @@
D5E4CC64202C1AC1009B4FFC /* MainWindowController+Scriptability.swift in Sources */,
84C9FC7922629E1200D921D6 /* PreferencesWindowController.swift in Sources */,
84411E711FE5FBFA004B527F /* SmallIconProvider.swift in Sources */,
84CAFCA422BC8C08007694F0 /* FetchRequestQueue.swift in Sources */,
844B5B591FE9FE4F00C7C76A /* SidebarKeyboardDelegate.swift in Sources */,
84C9FC7C22629E1200D921D6 /* AccountsPreferencesViewController.swift in Sources */,
51EC114C2149FE3300B296E3 /* FolderTreeMenu.swift in Sources */,

View File

@ -18,9 +18,11 @@ struct SearchFeedDelegate: SmartFeedDelegate {
let nameForDisplayPrefix = NSLocalizedString("Search: ", comment: "Search smart feed title prefix")
let searchString: String
let fetchType: FetchType
init(searchString: String) {
self.searchString = searchString
self.fetchType = .search(searchString)
}
func fetchUnreadCount(for: Account, callback: @escaping (Int) -> Void) {
@ -28,19 +30,3 @@ struct SearchFeedDelegate: SmartFeedDelegate {
}
}
// MARK: - ArticleFetcher
extension SearchFeedDelegate: ArticleFetcher {
func fetchArticles() -> Set<Article> {
var articles = Set<Article>()
for account in AccountManager.shared.activeAccounts {
articles.formUnion(account.fetchArticlesMatching(searchString))
}
return articles
}
func fetchUnreadArticles() -> Set<Article> {
return fetchArticles().unreadArticles()
}
}

View File

@ -11,10 +11,6 @@ import RSCore
import Articles
import Account
protocol SmartFeedDelegate: DisplayNameProvider, ArticleFetcher {
func fetchUnreadCount(for: Account, callback: @escaping (Int) -> Void)
}
final class SmartFeed: PseudoFeed {
var nameForDisplay: String {
@ -61,9 +57,17 @@ extension SmartFeed: ArticleFetcher {
return delegate.fetchArticles()
}
func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
delegate.fetchArticlesAsync(callback)
}
func fetchUnreadArticles() -> Set<Article> {
return delegate.fetchUnreadArticles()
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
delegate.fetchUnreadArticlesAsync(callback)
}
}
private extension SmartFeed {

View File

@ -0,0 +1,38 @@
//
// SmartFeedDelegate.swift
// NetNewsWire
//
// Created by Brent Simmons on 6/25/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Articles
import RSCore
protocol SmartFeedDelegate: DisplayNameProvider, ArticleFetcher {
var fetchType: FetchType { get }
func fetchUnreadCount(for: Account, callback: @escaping (Int) -> Void)
}
extension SmartFeedDelegate {
func fetchArticles() -> Set<Article> {
return AccountManager.shared.fetchArticles(fetchType)
}
func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
AccountManager.shared.fetchArticlesAsync(fetchType, callback)
}
func fetchUnreadArticles() -> Set<Article> {
return fetchArticles().unreadArticles()
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync{ callback($0.unreadArticles()) }
}
}

View File

@ -10,30 +10,14 @@ import Foundation
import Articles
import Account
// Main thread only.
struct StarredFeedDelegate: SmartFeedDelegate {
let nameForDisplay = NSLocalizedString("Starred", comment: "Starred pseudo-feed title")
let fetchType: FetchType = .starred
func fetchUnreadCount(for account: Account, callback: @escaping (Int) -> Void) {
account.fetchUnreadCountForStarredArticles(callback)
}
// MARK: ArticleFetcher
func fetchArticles() -> Set<Article> {
var articles = Set<Article>()
for account in AccountManager.shared.activeAccounts {
articles.formUnion(account.fetchStarredArticles())
}
return articles
}
func fetchUnreadArticles() -> Set<Article> {
return fetchArticles().unreadArticles()
}
}

View File

@ -13,26 +13,10 @@ import Account
struct TodayFeedDelegate: SmartFeedDelegate {
let nameForDisplay = NSLocalizedString("Today", comment: "Today pseudo-feed title")
let fetchType = FetchType.today
func fetchUnreadCount(for account: Account, callback: @escaping (Int) -> Void) {
account.fetchUnreadCountForToday(callback)
}
// MARK: ArticleFetcher
func fetchArticles() -> Set<Article> {
var articles = Set<Article>()
for account in AccountManager.shared.activeAccounts {
articles.formUnion(account.fetchTodayArticles())
}
return articles
}
func fetchUnreadArticles() -> Set<Article> {
return fetchArticles().unreadArticles()
}
}

View File

@ -19,7 +19,8 @@ import Articles
final class UnreadFeed: PseudoFeed {
let nameForDisplay = NSLocalizedString("All Unread", comment: "All Unread pseudo-feed title")
let fetchType = FetchType.unread
var unreadCount = 0 {
didSet {
if unreadCount != oldValue {
@ -50,16 +51,18 @@ final class UnreadFeed: PseudoFeed {
extension UnreadFeed: ArticleFetcher {
func fetchArticles() -> Set<Article> {
return fetchUnreadArticles()
}
func fetchUnreadArticles() -> Set<Article> {
func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(callback)
}
var articles = Set<Article>()
for account in AccountManager.shared.activeAccounts {
articles.formUnion(account.fetchUnreadArticles())
}
return articles
func fetchUnreadArticles() -> Set<Article> {
return AccountManager.shared.fetchArticles(fetchType)
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
AccountManager.shared.fetchArticlesAsync(fetchType, callback)
}
}