diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index c54941a04..24f2a29f2 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -1043,6 +1043,10 @@ private extension Account { return try fetchUnreadArticles(forContainer: self, limit: limit) } + func fetchUnreadArticlesBetween(limit: Int?, before: Date?, after: Date?) throws -> Set
{ + return try fetchUnreadArticlesBetween(forContainer: self, limit: limit, before: before, after: after) + } + func fetchUnreadArticlesAsync(limit: Int?, _ completion: @escaping ArticleSetResultBlock) { fetchUnreadArticlesAsync(forContainer: self, limit: limit, completion) } @@ -1152,6 +1156,19 @@ private extension Account { return articles } + func fetchUnreadArticlesBetween(forContainer container: Container, limit: Int?, before: Date?, after: Date?) throws -> Set
{ + let feeds = container.flattenedWebFeeds() + let articles = try database.fetchUnreadArticlesBetween(feeds.webFeedIDs(), limit, before, after) + + // We don't validate limit queries because they, by definition, won't correctly match the + // complete unread state for the given container. + if limit == nil { + validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles) + } + + return articles + } + func fetchUnreadArticlesAsync(forContainer container: Container, limit: Int?, _ completion: @escaping ArticleSetResultBlock) { let webFeeds = container.flattenedWebFeeds() database.fetchUnreadArticlesAsync(webFeeds.webFeedIDs(), limit) { [weak self] (articleSetResult) in diff --git a/Account/Sources/Account/ArticleFetcher.swift b/Account/Sources/Account/ArticleFetcher.swift index 308cb155e..aefcaaf43 100644 --- a/Account/Sources/Account/ArticleFetcher.swift +++ b/Account/Sources/Account/ArticleFetcher.swift @@ -15,6 +15,7 @@ public protocol ArticleFetcher { func fetchArticles() throws -> Set
func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock) func fetchUnreadArticles() throws -> Set
+ func fetchUnreadArticlesBetween(before: Date?, after: Date?) throws -> Set
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) } @@ -36,6 +37,10 @@ extension WebFeed: ArticleFetcher { public func fetchUnreadArticles() throws -> Set
{ return try fetchArticles().unreadArticles() } + + public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try fetchArticles().unreadArticlesBetween(before: before, after: after) + } public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { guard let account = account else { @@ -81,6 +86,10 @@ extension Folder: ArticleFetcher { return try account.fetchArticles(.folder(self, true)) } + public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try fetchArticles().unreadArticlesBetween(before: before, after: after) + } + public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { guard let account = account else { assertionFailure("Expected folder.account, but got nil.") diff --git a/Account/Sources/Account/SingleArticleFetcher.swift b/Account/Sources/Account/SingleArticleFetcher.swift index f558fd5fa..31104fd09 100644 --- a/Account/Sources/Account/SingleArticleFetcher.swift +++ b/Account/Sources/Account/SingleArticleFetcher.swift @@ -31,6 +31,10 @@ public struct SingleArticleFetcher: ArticleFetcher { public func fetchUnreadArticles() throws -> Set
{ return try account.fetchArticles(.articleIDs(Set([articleID]))) } + + public func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try account.fetchArticles(.articleIDs(Set([articleID]))) + } public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { return account.fetchArticlesAsync(.articleIDs(Set([articleID])), completion) diff --git a/Articles/Sources/Articles/Article.swift b/Articles/Sources/Articles/Article.swift index a979c7229..19bf2936b 100644 --- a/Articles/Sources/Articles/Article.swift +++ b/Articles/Sources/Articles/Article.swift @@ -79,6 +79,19 @@ public extension Set where Element == Article { let articles = self.filter { !$0.status.read } return Set(articles) } + + func unreadArticlesBetween(before: Date? = nil, after: Date? = nil) -> Set
{ + var articles = self.filter { !$0.status.read } + if before != nil { + // TODO: Address datePublished nil + articles = articles.filter { $0.datePublished != nil && $0.datePublished! <= before! } + } + if after != nil { + // TODO: Address datePublished nil + articles = articles.filter { $0.datePublished != nil && $0.datePublished! >= after! } + } + return Set(articles) + } func contains(accountID: String, articleID: String) -> Bool { return contains(where: { $0.accountID == accountID && $0.articleID == articleID}) diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift index fae1df9b2..ff4a7148f 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift @@ -106,6 +106,10 @@ public final class ArticlesDatabase { return try articlesTable.fetchUnreadArticles(webFeedIDs, limit) } + public func fetchUnreadArticlesBetween(_ webFeedIDs: Set, _ limit: Int?, _ before: Date?, _ after: Date?) throws -> Set
{ + return try articlesTable.fetchUnreadArticlesBetween(webFeedIDs, limit, before, after) + } + public func fetchTodayArticles(_ webFeedIDs: Set, _ limit: Int?) throws -> Set
{ return try articlesTable.fetchArticlesSince(webFeedIDs, todayCutoffDate(), limit) } diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index a64be83dc..e7768af8d 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -75,11 +75,15 @@ final class ArticlesTable: DatabaseTable { } // MARK: - Fetching Unread Articles - + func fetchUnreadArticles(_ webFeedIDs: Set, _ limit: Int?) throws -> Set
{ return try fetchArticles{ self.fetchUnreadArticles(webFeedIDs, limit, $0) } } + func fetchUnreadArticlesBetween(_ webFeedIDs: Set, _ limit: Int?, _ before: Date?, _ after: Date?) throws -> Set
{ + return try fetchArticles{ self.fetchUnreadArticlesBetween(webFeedIDs, limit, $0, before, after) } + } + func fetchUnreadArticlesAsync(_ webFeedIDs: Set, _ limit: Int?, _ completion: @escaping ArticleSetResultBlock) { fetchArticlesAsync({ self.fetchUnreadArticles(webFeedIDs, limit, $0) }, completion) } @@ -845,6 +849,23 @@ private extension ArticlesTable { return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } + func fetchUnreadArticlesBetween(_ webFeedIDs: Set, _ limit: Int?, _ database: FMDatabase, _ before: Date?, _ after: Date?) -> Set
{ + // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0 + if webFeedIDs.isEmpty { + return Set
() + } + let parameters = webFeedIDs.map { $0 as AnyObject } + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! + var whereClause = "feedID in \(placeholders) and read=0" + if let limit = limit { + whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") + } + if let before = before { + whereClause.append(" and dateArrived <= \(before)") + } + return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) + } + func fetchArticlesForFeedID(_ webFeedID: String, _ database: FMDatabase) -> Set
{ return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [webFeedID as AnyObject]) } diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift index bb53a36a2..95d06d788 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift @@ -167,6 +167,41 @@ final class StatusesTable: DatabaseTable { } } + func fetchArticleIDsArrivedBetweenDates(beforeDate: Date?, afterDate: Date?, completion: @escaping ArticleIDsCompletionBlock) { + queue.runInDatabase { databaseResult in + + var error: DatabaseError? + var articleIDs = Set() + + var sql = "select artcileID from statuses s" + + func makeDatabaseCall(_ database: FMDatabase) { + let sql = "select articleID from statuses s where (starred=1 or dateArrived>?) and not exists (select 1 from articles a where a.articleID = s.articleID);" + if let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) { + articleIDs = resultSet.mapToSet(self.articleIDWithRow) + } + } + + switch databaseResult { + case .success(let database): + makeDatabaseCall(database) + case .failure(let databaseError): + error = databaseError + } + + if let error = error { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + else { + DispatchQueue.main.async { + completion(.success(articleIDs)) + } + } + } + } + func fetchArticleIDs(_ sql: String) throws -> Set { var error: DatabaseError? var articleIDs = Set() diff --git a/Shared/SmartFeeds/SmartFeed.swift b/Shared/SmartFeeds/SmartFeed.swift index e8245a0b9..e77b218e4 100644 --- a/Shared/SmartFeeds/SmartFeed.swift +++ b/Shared/SmartFeeds/SmartFeed.swift @@ -95,6 +95,10 @@ extension SmartFeed: ArticleFetcher { return try delegate.fetchUnreadArticles() } + func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try delegate.fetchUnreadArticlesBetween(before: before, after: after) + } + func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { delegate.fetchUnreadArticlesAsync(completion) } diff --git a/Shared/SmartFeeds/SmartFeedDelegate.swift b/Shared/SmartFeeds/SmartFeedDelegate.swift index a8bb89e96..dfd7b4822 100644 --- a/Shared/SmartFeeds/SmartFeedDelegate.swift +++ b/Shared/SmartFeeds/SmartFeedDelegate.swift @@ -31,6 +31,10 @@ extension SmartFeedDelegate { return try fetchArticles().unreadArticles() } + func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try fetchArticles().unreadArticlesBetween(before: before, after: after) + } + func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { fetchArticlesAsync{ articleSetResult in switch articleSetResult { diff --git a/Shared/SmartFeeds/UnreadFeed.swift b/Shared/SmartFeeds/UnreadFeed.swift index eb9f4fb9c..00e20c509 100644 --- a/Shared/SmartFeeds/UnreadFeed.swift +++ b/Shared/SmartFeeds/UnreadFeed.swift @@ -73,10 +73,14 @@ extension UnreadFeed: ArticleFetcher { func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { fetchUnreadArticlesAsync(completion) } - + func fetchUnreadArticles() throws -> Set
{ return try AccountManager.shared.fetchArticles(fetchType) } + + func fetchUnreadArticlesBetween(before: Date? = nil, after: Date? = nil) throws -> Set
{ + return try AccountManager.shared.fetchArticles(fetchType) + } func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) { AccountManager.shared.fetchArticlesAsync(fetchType, completion) diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 5a4991429..8af457743 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -905,16 +905,12 @@ private extension MasterFeedViewController { menuElements.append(UIMenu(title: "", options: .displayInline, children: pageActions)) } - var markActions = [UIAction]() if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) { - markActions.append(markAllAction) - } - if !markActions.isEmpty { - menuElements.append(UIMenu(title: "", options: .displayInline, children: markActions)) - + menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction])) + } - if let catchUpAction = self.catchUpAction(indexPath: indexPath) { + if let catchUpAction = self.catchUpActionMenu(indexPath: indexPath) { menuElements.append(catchUpAction) } @@ -1148,60 +1144,66 @@ private extension MasterFeedViewController { return action } - func catchUpAction(indexPath: IndexPath) -> UIMenu? { + func catchUpActionMenu(indexPath: IndexPath) -> UIMenu? { guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed, let contentView = self.tableView.cellForRow(at: indexPath)?.contentView, feed.unreadCount > 0 else { return nil } - let localizedMenuText = NSLocalizedString("Mark Older Than as Read...", comment: "Command") - let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) as String + let title = NSLocalizedString("Mark Older Than as Read...", comment: "Command") let oneDayAction = UIAction(title: "1 Day") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 1 Day as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .day, value: -1, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let twoDayAction = UIAction(title: "2 Days") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 2 Days as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .day, value: -2, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let threeDayAction = UIAction(title: "3 Days") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 3 Days as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .day, value: -3, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let oneWeekAction = UIAction(title: "1 Week") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 1 Week as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let twoWeekAction = UIAction(title: "2 Weeks") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 2 Weeks as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .weekOfYear, value: -2, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let oneMonthAction = UIAction(title: "1 Month") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 1 Month as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .month, value: -1, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } } let oneYearAction = UIAction(title: "1 Year") { [weak self] action in - MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in - if let articles = try? feed.fetchUnreadArticles() { + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: "Mark Older Than 1 Year as Read", sourceType: contentView) { [weak self] in + let cutoff = Calendar.current.date(byAdding: .year, value: -1, to: Date()) + if let articles = try? feed.fetchUnreadArticlesBetween(before: cutoff, after: nil) { self?.coordinator.markAllAsRead(Array(articles)) } } diff --git a/iOS/MasterTimeline/MarkAsReadAlertController.swift b/iOS/MasterTimeline/MarkAsReadAlertController.swift index fe37f2df8..5bbce1406 100644 --- a/iOS/MasterTimeline/MarkAsReadAlertController.swift +++ b/iOS/MasterTimeline/MarkAsReadAlertController.swift @@ -46,30 +46,23 @@ struct MarkAsReadAlertController { completion: @escaping (UIAlertAction) -> Void) -> UIAlertController where T: MarkAsReadAlertControllerSourceType { - let title = NSLocalizedString("Mark as Read", comment: "Catch Up") - let message = NSLocalizedString("Mark articles as read older than", - comment: "Mark articles as read older than") + let title = NSLocalizedString("Mark As Read", comment: "Mark As Read") + let message = NSLocalizedString("You can turn this confirmation off in Settings.", + comment: "You can turn this confirmation off in Settings.") let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") + let settingsTitle = NSLocalizedString("Open Settings", comment: "Open Settings") let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { _ in cancelCompletion?() } - let oneDayAction = UIAlertAction(title: "1 Day", style: .default, handler: completion) - let twoDaysAction = UIAlertAction(title: "2 Days", style: .default, handler: completion) - let threeDaysAction = UIAlertAction(title: "3 Days", style: .default, handler: completion) - let oneWeekAction = UIAlertAction(title: "1 Week", style: .default, handler: completion) - let twoWeeksAction = UIAlertAction(title: "2 Weeks", style: .default, handler: completion) - let oneMonthAction = UIAlertAction(title: "1 Month", style: .default, handler: completion) - let oneYearAction = UIAlertAction(title: "1 Year", style: .default, handler: completion) + let settingsAction = UIAlertAction(title: settingsTitle, style: .default) { _ in + coordinator.showSettings(scrollToArticlesSection: true) + } + let markAction = UIAlertAction(title: confirmTitle, style: .default, handler: completion) - alertController.addAction(oneDayAction) - alertController.addAction(twoDaysAction) - alertController.addAction(threeDaysAction) - alertController.addAction(oneWeekAction) - alertController.addAction(twoWeeksAction) - alertController.addAction(oneMonthAction) - alertController.addAction(oneYearAction) + alertController.addAction(markAction) + alertController.addAction(settingsAction) alertController.addAction(cancelAction) if let barButtonItem = sourceType as? UIBarButtonItem {