Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Mac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ let appName = "NetNewsWire"
private var aboutWindowController: AboutWindowController?
private var addFeedController: AddFeedController?
private var addFolderWindowController: AddFolderWindowController?
private var addSmartFeedWindowController: AddSmartFeedWindowController?
private var importOPMLController: ImportOPMLWindowController?
private var exportOPMLController: ExportOPMLWindowController?
private var keyboardShortcutsWindowController: WebViewWindowController?
Expand Down Expand Up @@ -139,6 +140,11 @@ let appName = "NetNewsWire"
addFeedController?.showAddFeedSheet(urlString, name, account, folder)
}

func showAddSmartFeedSheetOnWindow(_ window: NSWindow, userSmartFeed: UserSmartFeed? = nil) {
addSmartFeedWindowController = AddSmartFeedWindowController(userSmartFeed: userSmartFeed)
addSmartFeedWindowController!.runSheetOnWindow(window)
}

// MARK: - NSApplicationDelegate

func applicationWillFinishLaunching(_ notification: Notification) {
Expand Down Expand Up @@ -498,7 +504,7 @@ let appName = "NetNewsWire"
return mainWindowController?.isOpen ?? false
}

if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) {
if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) || item.action == #selector(showAddSmartFeedWindow(_:)) {
return !isDisplayingSheet && !AccountManager.shared.activeAccounts.isEmpty
}

Expand Down Expand Up @@ -629,6 +635,11 @@ let appName = "NetNewsWire"
showAddFolderSheetOnWindow(windowController.window!)
}

@IBAction func showAddSmartFeedWindow(_ sender: Any?) {
let windowController = createAndShowMainWindowIfNecessary()
showAddSmartFeedSheetOnWindow(windowController.window!)
}

@IBAction func showKeyboardShortcutsWindow(_ sender: Any?) {
if keyboardShortcutsWindowController == nil {

Expand Down
5 changes: 5 additions & 0 deletions Mac/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
<action selector="showAddFolderWindow:" target="Ady-hI-5gd" id="GIi-wc-uYk"/>
</connections>
</menuItem>
<menuItem title="New Smart Feed…" id="SmF-ee-d01">
<connections>
<action selector="showAddSmartFeedWindow:" target="Ady-hI-5gd" id="SmF-ac-t01"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="New Window" keyEquivalent="n" id="pGg-Gc-PU2">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
Expand Down
45 changes: 45 additions & 0 deletions Mac/MainWindow/AddSmartFeed/AddSmartFeedWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// AddSmartFeedWindowController.swift
// NetNewsWire
//
// Hosts the shared SwiftUI smart-feed editor in a sheet on Mac.
//

import AppKit
import SwiftUI

@MainActor final class AddSmartFeedWindowController: NSWindowController {

private var hostWindow: NSWindow?

convenience init(userSmartFeed: UserSmartFeed? = nil) {
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 460, height: 400),
styleMask: [.titled],
backing: .buffered,
defer: false)
self.init(window: window)

let editorView = SmartFeedEditorView(userSmartFeed: userSmartFeed) { [weak self] in
self?.endSheet()
}
window.contentViewController = NSHostingController(rootView: editorView)
}

func runSheetOnWindow(_ hostWindow: NSWindow) {
guard let window else {
return
}
self.hostWindow = hostWindow
Task { @MainActor in
await hostWindow.beginSheet(window)
}
}

private func endSheet() {
guard let hostWindow, let window else {
return
}
hostWindow.endSheet(window)
self.hostWindow = nil
}
}
41 changes: 41 additions & 0 deletions Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,38 @@ extension SidebarViewController {
window.beginSheet(renameSheet)
}

@objc func editSmartFeedFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem,
let userSmartFeed = menuItem.representedObject as? UserSmartFeed,
let window = view.window else {
return
}
appDelegate.showAddSmartFeedSheetOnWindow(window, userSmartFeed: userSmartFeed)
}

@objc func deleteSmartFeedFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem,
let userSmartFeed = menuItem.representedObject as? UserSmartFeed,
let window = view.window else {
return
}

let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("Delete Smart Feed", comment: "Command")
let formatString = NSLocalizedString("Are you sure you want to delete the “%@” smart feed?", comment: "Smart feed delete text")
alert.informativeText = NSString.localizedStringWithFormat(formatString as NSString, userSmartFeed.name) as String
alert.addButton(withTitle: NSLocalizedString("Delete", comment: "Delete button"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button"))
alert.buttons[0].hasDestructiveAction = true

alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
SmartFeedsController.shared.removeUserSmartFeed(userSmartFeed)
}
}
}

@objc func toggleNotificationsFromContextMenu(_ sender: Any?) {
guard let item = sender as? NSMenuItem,
let feed = item.representedObject as? Feed else {
Expand Down Expand Up @@ -199,6 +231,7 @@ private extension SidebarViewController {

menu.addItem(withTitle: NSLocalizedString("New Feed", comment: "Command"), action: #selector(AppDelegate.showAddFeedWindow(_:)), keyEquivalent: "")
menu.addItem(withTitle: NSLocalizedString("New Folder", comment: "Command"), action: #selector(AppDelegate.showAddFolderWindow(_:)), keyEquivalent: "")
menu.addItem(withTitle: NSLocalizedString("New Smart Feed", comment: "Command"), action: #selector(AppDelegate.showAddSmartFeedWindow(_:)), keyEquivalent: "")

return menu
}
Expand Down Expand Up @@ -269,6 +302,14 @@ private extension SidebarViewController {
if smartFeed.unreadCount > 0 {
menu.addItem(markAllReadMenuItem([smartFeed]))
}

// Only user-created smart feeds can be edited or deleted.
if let userSmartFeed = SmartFeedsController.shared.userSmartFeed(for: smartFeed.sidebarItemID) {
menu.addSeparatorIfNeeded()
menu.addItem(menuItem(NSLocalizedString("Edit Smart Feed", comment: "Command"), #selector(editSmartFeedFromContextualMenu(_:)), userSmartFeed))
menu.addItem(menuItem(NSLocalizedString("Delete Smart Feed", comment: "Command"), #selector(deleteSmartFeedFromContextualMenu(_:)), userSmartFeed))
}

return menu.numberOfItems > 0 ? menu : nil
}

Expand Down
17 changes: 17 additions & 0 deletions Modules/Account/Sources/Account/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public enum FetchType {
case articleIDs(Set<String>)
case search(String)
case searchWithArticleIDs(String, Set<String>)
case smartFeedCriteria(SmartFeedCriteria)
}

@MainActor public final class Account: ProgressInfoReporter, DisplayNameProvider, UnreadCountProvider, Container, Hashable {
Expand Down Expand Up @@ -800,6 +801,8 @@ public enum FetchType {
return _fetchArticlesMatching(searchString: searchString)
case .searchWithArticleIDs(let searchString, let articleIDs):
return _fetchArticlesMatchingWithArticleIDs(searchString: searchString, articleIDs: articleIDs)
case .smartFeedCriteria(let criteria):
return _fetchArticlesMatchingCriteria(criteria)
}
}

Expand All @@ -825,9 +828,15 @@ public enum FetchType {
return await _fetchArticlesMatchingAsync(searchString: searchString)
case .searchWithArticleIDs(let searchString, let articleIDs):
return await _fetchArticlesMatchingWithArticleIDsAsync(searchString: searchString, articleIDs: articleIDs)
case .smartFeedCriteria(let criteria):
return await _fetchArticlesMatchingCriteriaAsync(criteria)
}
}

public func fetchUnreadCountMatchingCriteriaAsync(_ criteria: SmartFeedCriteria) async -> Int {
await database.fetchUnreadCountMatchingCriteriaAsync(criteria: criteria, feedIDs: flattenedFeedsIDs)
}

public func fetchUnreadCountForStarredArticlesAsync() async -> Int {
await database.fetchUnreadCountForStarredArticlesAsync(feedIDs: flattenedFeedsIDs)
}
Expand Down Expand Up @@ -1288,6 +1297,14 @@ private extension Account {
await database.fetchArticlesMatchingAsync(searchString: searchString, feedIDs: flattenedFeedsIDs)
}

func _fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria) -> Set<Article> {
database.fetchArticlesMatchingCriteria(criteria: criteria, feedIDs: flattenedFeedsIDs)
}

func _fetchArticlesMatchingCriteriaAsync(_ criteria: SmartFeedCriteria) async -> Set<Article> {
await database.fetchArticlesMatchingCriteriaAsync(criteria: criteria, feedIDs: flattenedFeedsIDs)
}

func _fetchArticlesMatchingWithArticleIDs(searchString: String, articleIDs: Set<String>) -> Set<Article> {
database.fetchArticlesMatchingWithArticleIDs(searchString: searchString, articleIDs: articleIDs)
}
Expand Down
8 changes: 8 additions & 0 deletions Modules/ArticlesDatabase/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ let package = Package(
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
),
.testTarget(
name: "ArticlesDatabaseTests",
dependencies: ["ArticlesDatabase"],
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ public struct ArticleCounts: Sendable {
return articlesTable.fetchArticlesMatching(searchString, feedIDs)
}

public func fetchArticlesMatchingCriteria(criteria: SmartFeedCriteria, feedIDs: Set<String>) -> Set<Article> {
Self.logger.debug("ArticlesDatabase: \(#function, privacy: .public) \(self.accountID, privacy: .public)")
return articlesTable.fetchArticlesMatchingCriteria(criteria, feedIDs)
}

public func fetchArticlesMatchingWithArticleIDs(searchString: String, articleIDs: Set<String>) -> Set<Article> {
Self.logger.debug("ArticlesDatabase: \(#function, privacy: .public) \(self.accountID, privacy: .public)")
return articlesTable.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs)
Expand Down Expand Up @@ -231,6 +236,14 @@ public struct ArticleCounts: Sendable {
}
}

public func fetchArticlesMatchingCriteriaAsync(criteria: SmartFeedCriteria, feedIDs: Set<String>) async -> Set<Article> {
await withCheckedContinuation { continuation in
articlesTable.fetchArticlesMatchingCriteriaAsync(criteria, feedIDs) { articles in
continuation.resume(returning: articles)
}
}
}

public func fetchArticlesMatchingWithArticleIDsAsync(searchString: String, articleIDs: Set<String>) async -> Set<Article> {
await withCheckedContinuation { continuation in
_fetchArticlesMatchingWithArticleIDsAsync(searchString: searchString, articleIDs: articleIDs) { articles in
Expand Down Expand Up @@ -289,6 +302,14 @@ public struct ArticleCounts: Sendable {
}
}

public func fetchUnreadCountMatchingCriteriaAsync(criteria: SmartFeedCriteria, feedIDs: Set<String>) async -> Int {
await withCheckedContinuation { continuation in
articlesTable.fetchUnreadCountMatchingCriteriaAsync(criteria, feedIDs) { unreadCount in
continuation.resume(returning: unreadCount)
}
}
}

public func fetchTodayArticlesCountAsync(feedIDs: Set<String>) async -> Int {
await withCheckedContinuation { continuation in
articlesTable.fetchArticlesCountSince(feedIDs, todayCutoffDate()) { count in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ final class ArticlesTable: DatabaseTable, Sendable {
fetchArticlesCount { self.fetchStarredArticlesCount(feedIDs, $0) }
}

// MARK: - Fetching Custom Smart Feed (Criteria) Articles

func fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set<String>) -> Set<Article> {
fetchArticles { self.fetchArticlesMatchingCriteria(criteria, feedIDs, $0) }
}

func fetchArticlesMatchingCriteriaAsync(_ criteria: SmartFeedCriteria, _ feedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
fetchArticlesAsync({ self.fetchArticlesMatchingCriteria(criteria, feedIDs, $0) }, completion)
}

func fetchUnreadCountMatchingCriteriaAsync(_ criteria: SmartFeedCriteria, _ feedIDs: Set<String>, _ completion: @escaping SingleUnreadCountCompletionBlock) {
if feedIDs.isEmpty {
completion(0)
return
}
queue.runInDatabase { database in
let count = self.fetchUnreadCountMatchingCriteria(criteria, feedIDs, database)
DispatchQueue.main.async {
completion(count)
}
}
}

// MARK: - Fetching Counts Async

func fetchArticleCountsAsync(_ feedIDs: Set<String>, _ completion: @escaping @Sendable (ArticleCounts) -> Void) {
Expand Down Expand Up @@ -803,6 +826,31 @@ nonisolated private extension ArticlesTable {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject])
}

func fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in (…) and (<criteria>)
if feedIDs.isEmpty {
return Set<Article>()
}
let (criteriaClause, criteriaParameters) = criteria.whereClauseAndParameters(forAccountID: accountID)
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (\(criteriaClause))"
var parameters = feedIDs.map { $0 as AnyObject }
parameters += criteriaParameters.map { $0 as AnyObject }
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}

func fetchUnreadCountMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set<String>, _ database: FMDatabase) -> Int {
if feedIDs.isEmpty {
return 0
}
let (criteriaClause, criteriaParameters) = criteria.whereClauseAndParameters(forAccountID: accountID)
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (\(criteriaClause)) and read=0"
var parameters = feedIDs.map { $0 as AnyObject }
parameters += criteriaParameters.map { $0 as AnyObject }
return fetchArticleCountsWithWhereClause(database, whereClause: whereClause, parameters: parameters)
}

func fetchArticles(articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
Expand Down
Loading