From fe2e0155da23533dd1972a9ad5b4e1756f4e8ede Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 31 Aug 2019 15:53:47 -0500 Subject: [PATCH] Add scoped searching of articles --- Frameworks/Account/Account.swift | 13 +++++++ .../ArticlesDatabase/ArticlesDatabase.swift | 8 ++++ .../ArticlesDatabase/ArticlesTable.swift | 39 +++++++++++-------- NetNewsWire.xcodeproj/project.pbxproj | 6 +++ .../SearchTimelineFeedDelegate.swift | 31 +++++++++++++++ iOS/AppCoordinator.swift | 33 +++++++++++++--- .../MasterTimelineViewController.swift | 24 ++++++++++-- 7 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 Shared/SmartFeeds/SearchTimelineFeedDelegate.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index bc510b1bc..c6b692c54 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -48,6 +48,7 @@ public enum FetchType { case feed(Feed) case articleIDs(Set) case search(String) + case searchWithArticleIDs(String, Set) } public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable { @@ -534,6 +535,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return fetchArticles(articleIDs: articleIDs) case .search(let searchString): return fetchArticlesMatching(searchString) + case .searchWithArticleIDs(let searchString, let articleIDs): + return fetchArticlesMatchingWithArticleIDs(searchString, articleIDs) } } @@ -553,6 +556,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, fetchArticlesAsync(articleIDs: articleIDs, callback) case .search(let searchString): fetchArticlesMatchingAsync(searchString, callback) + case .searchWithArticleIDs(let searchString, let articleIDs): + return fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, callback) } } @@ -862,10 +867,18 @@ private extension Account { return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs()) } + func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set) -> Set
{ + return database.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs) + } + func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) { database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback) } + func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set, _ callback: @escaping ArticleSetBlock) { + database.fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, callback) + } + func fetchArticles(articleIDs: Set) -> Set
{ return database.fetchArticles(articleIDs: articleIDs) } diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index d3e455e36..312a02bee 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -68,6 +68,10 @@ public final class ArticlesDatabase { return articlesTable.fetchArticlesMatching(searchString, feedIDs) } + public func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set) -> Set
{ + return articlesTable.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs) + } + // MARK: - Fetching Articles Async public func fetchArticlesAsync(_ feedID: String, _ callback: @escaping ArticleSetBlock) { @@ -94,6 +98,10 @@ public final class ArticlesDatabase { articlesTable.fetchArticlesMatchingAsync(searchString, feedIDs, callback) } + public func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, callback) + } + // MARK: - Unread Counts public func fetchUnreadCounts(for feedIDs: Set, _ callback: @escaping UnreadCountCompletionBlock) { diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 3ac4de861..252da0098 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -146,39 +146,46 @@ final class ArticlesTable: DatabaseTable { // MARK: - Fetching Search Articles - func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set) -> Set
{ + func fetchArticlesMatching(_ searchString: String) -> Set
{ var articles: Set
= Set
() queue.fetchSync { (database) in articles = self.fetchArticlesMatching(searchString, database) } + return articles + } + + func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set) -> Set
{ + var articles = fetchArticlesMatching(searchString) articles = articles.filter{ feedIDs.contains($0.feedID) } return articles } + func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set) -> Set
{ + var articles = fetchArticlesMatching(searchString) + articles = articles.filter{ articleIDs.contains($0.articleID) } + return articles + } + func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { fetchArticlesAsync({ self.fetchArticlesMatching(searchString, feedIDs, $0) }, callback) } - private func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set, _ database: FMDatabase) -> Set
{ - 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
() - } - let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) } - if searchRowIDs.isEmpty { - return Set
() - } + func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync({ self.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs, $0) }, callback) + } - 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) + private func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set, _ database: FMDatabase) -> Set
{ + let articles = fetchArticlesMatching(searchString, database) // TODO: include the feedIDs in the SQL rather than filtering here. return articles.filter{ feedIDs.contains($0.feedID) } } + private func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set, _ database: FMDatabase) -> Set
{ + let articles = fetchArticlesMatching(searchString, database) + // TODO: include the articleIDs in the SQL rather than filtering here. + return articles.filter{ articleIDs.contains($0.articleID) } + } + // MARK: - Fetching Articles for Indexer func fetchArticleSearchInfos(_ articleIDs: Set, in database: FMDatabase) -> Set? { diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 216a5ba34..a778ffa7b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ 51934CCB230F599B006127BE /* ThemedNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51934CC1230F5963006127BE /* ThemedNavigationController.swift */; }; 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51934CCD2310792F006127BE /* ActivityManager.swift */; }; 51934CD023108953006127BE /* ActivityID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51934CCF23108953006127BE /* ActivityID.swift */; }; + 51938DF2231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */; }; + 51938DF3231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */; }; 519B8D332143397200FA689C /* SharingServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519B8D322143397200FA689C /* SharingServiceDelegate.swift */; }; 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E743422C663F900A78E47 /* SceneDelegate.swift */; }; 51C451A9226377C200C03939 /* ArticlesDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8407167F2262A61100344432 /* ArticlesDatabase.framework */; }; @@ -710,6 +712,7 @@ 51934CC1230F5963006127BE /* ThemedNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedNavigationController.swift; sourceTree = ""; }; 51934CCD2310792F006127BE /* ActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityManager.swift; sourceTree = ""; }; 51934CCF23108953006127BE /* ActivityID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityID.swift; sourceTree = ""; }; + 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTimelineFeedDelegate.swift; sourceTree = ""; }; 5194B5ED22B6965300144881 /* SettingsSubscriptionsImportDocumentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSubscriptionsImportDocumentPickerView.swift; sourceTree = ""; }; 5194B5F122B69FCC00144881 /* SettingsSubscriptionsExportDocumentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSubscriptionsExportDocumentPickerView.swift; sourceTree = ""; }; 519B8D322143397200FA689C /* SharingServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceDelegate.swift; sourceTree = ""; }; @@ -1813,6 +1816,7 @@ 84F2D5361FC22FCB00998D64 /* TodayFeedDelegate.swift */, 845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */, 8477ACBD22238E9500DF7F37 /* SearchFeedDelegate.swift */, + 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */, 84AD1EB92031649C00BC20B7 /* SmartFeedPasteboardWriter.swift */, ); path = SmartFeeds; @@ -2419,6 +2423,7 @@ 5126EE97226CB48A00C22AFC /* AppCoordinator.swift in Sources */, 84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, 51EF0F77227716200050506E /* FaviconGenerator.swift in Sources */, + 51938DF3231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */, 51C4525A226508D600C03939 /* UIStoryboard-Extensions.swift in Sources */, 5183CCEF227125970010922C /* SettingsViewController.swift in Sources */, 51F85BE5227217D000C787DC /* RefreshIntervalViewController.swift in Sources */, @@ -2565,6 +2570,7 @@ 849A97531ED9EAC0007D329B /* AddFeedController.swift in Sources */, 5183CCE8226F68D90010922C /* AccountRefreshTimer.swift in Sources */, 849A97831ED9EC63007D329B /* SidebarStatusBarView.swift in Sources */, + 51938DF2231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */, 84F2D5381FC22FCC00998D64 /* TodayFeedDelegate.swift in Sources */, 841ABA5E20145E9200980E11 /* FolderInspectorViewController.swift in Sources */, 84DEE56522C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */, diff --git a/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift b/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift new file mode 100644 index 000000000..398e60d8e --- /dev/null +++ b/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift @@ -0,0 +1,31 @@ +// +// SearchTimelineFeedDelegate.swift +// NetNewsWire +// +// Created by Maurice Parker on 8/31/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation +import Account +import Articles + +struct SearchTimelineFeedDelegate: SmartFeedDelegate { + + var nameForDisplay: String { + return nameForDisplayPrefix + searchString + } + + let nameForDisplayPrefix = NSLocalizedString("Search: ", comment: "Search smart feed title prefix") + let searchString: String + let fetchType: FetchType + + init(searchString: String, articleIDs: Set) { + self.searchString = searchString + self.fetchType = .searchWithArticleIDs(searchString, articleIDs) + } + + func fetchUnreadCount(for: Account, callback: @escaping (Int) -> Void) { + // TODO: after 5.0 + } +} diff --git a/iOS/AppCoordinator.swift b/iOS/AppCoordinator.swift index 415048466..f80b6d922 100644 --- a/iOS/AppCoordinator.swift +++ b/iOS/AppCoordinator.swift @@ -12,6 +12,11 @@ import Articles import RSCore import RSTree +enum SearchScope: Int { + case timeline = 0 + case global = 1 +} + class AppCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { var undoableCommands = [UndoableCommand]() @@ -49,7 +54,9 @@ class AppCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var expandedNodes = [Node]() private var shadowTable = [[Node]]() private var lastSearchString = "" + private var lastSearchScope: SearchScope? = nil private var isSearching: Bool = false + private var searchArticleIds: Set? = nil private(set) var sortDirection = AppDefaults.timelineSortDirection { didSet { @@ -517,25 +524,41 @@ class AppCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } } - func searchArticles(_ searchString: String) { + func searchArticles(_ searchString: String, _ searchScope: SearchScope) { guard !searchString.isEmpty else { isSearching = false + lastSearchString = "" + lastSearchScope = nil + searchArticleIds = nil + if let ip = currentMasterIndexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher { timelineFetcher = fetcher } + return } - isSearching = true - + if !isSearching { + isSearching = true + searchArticleIds = Set(articles.map { $0.articleID }) + } + if searchString.count < 3 { timelineFetcher = nil return } - if searchString != lastSearchString { - timelineFetcher = SmartFeed(delegate: SearchFeedDelegate(searchString: searchString)) + if searchString != lastSearchString || searchScope != lastSearchScope { + + switch searchScope { + case .global: + timelineFetcher = SmartFeed(delegate: SearchFeedDelegate(searchString: searchString)) + case .timeline: + timelineFetcher = SmartFeed(delegate: SearchTimelineFeedDelegate(searchString: searchString, articleIDs: searchArticleIds!)) + } + lastSearchString = searchString + lastSearchScope = searchScope } } diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 55d6161c5..c58e8b10e 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -44,10 +44,16 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner // Setup the Search Controller searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.delegate = self searchController.searchBar.placeholder = NSLocalizedString("Search Articles", comment: "Search Articles") + searchController.searchBar.showsScopeBar = true + searchController.searchBar.scopeButtonTitles = [ + NSLocalizedString("Here", comment: "Here"), + NSLocalizedString("All Articles", comment: "All Articles") + ] navigationItem.searchController = searchController definesPresentationContext = true - + // Setup the Refresh Control refreshControl = UIRefreshControl() refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged) @@ -403,12 +409,24 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } -// MARK: UISearchResultsUpdating +// MARK: Searching extension MasterTimelineViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { - coordinator.searchArticles(searchController.searchBar.text!) + let searchScope = SearchScope(rawValue: searchController.searchBar.selectedScopeButtonIndex)! + coordinator.searchArticles(searchController.searchBar.text!, searchScope) } + +} + +extension MasterTimelineViewController: UISearchBarDelegate { + + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + let searchScope = SearchScope(rawValue: selectedScope)! + coordinator.searchArticles(searchBar.text!, searchScope) + } + } // MARK: Private