diff --git a/AppleScript/Mail-CreateOutgoingMessage.applescript b/AppleScript/Mail-CreateOutgoingMessage.applescript new file mode 100644 index 000000000..0bd50c6e4 --- /dev/null +++ b/AppleScript/Mail-CreateOutgoingMessage.applescript @@ -0,0 +1,85 @@ +-- This script grabs the current article in NetNewsWire and copies relevant information about it +-- to a new outgoing message in Mail +-- the intended use is that the user wants to send email about the current article, and +-- would fill in the recipient and then send the message + +-- sometimes, an article has contents, and sometimes it has html contents +-- this function getContentsOrHtml() gets the contents as text, despite the representation +-- first it checks to see if there are plain text contents +-- if not, it looks for html contents, and converts those to plain text using a shell script that invokes textutil +-- if it can't find either plain text or html, it returns "couldn't find article text" +to getContentsOrHtml() + tell application "NetNewsWire" + set textContents to the contents of the current article + if textContents is not "" then + return textContents + else + set htmlContents to html of the current article + if htmlContents is not "" then + set shellScript to " echo '" & htmlContents & "' | /usr/bin/textutil -stdin -stdout -format html -convert txt" + set pureText to do shell script shellScript + return pureText + end if + end if + end tell + return "couldn't find article text" +end getContentsOrHtml + + +-- given a list of author names, generate a happily formatted list like "Jean MacDonald and James Dempsey" +-- if the list is more than two names, use Oxford comma structure: "Brent Simmons, Jean MacDonald, and James Dempsey" + +to formatListOfNames(listOfNames) + set c to count listOfNames + if c is 1 then + set formattedList to item 1 of listOfNames + else if c is 2 then + set formattedList to item 1 of listOfNames & " and " & item 2 of listOfNames + else + set frontOfList to items 1 thru (c - 1) of listOfNames + set lastName to item c of listOfNames + set tid to AppleScript's text item delimiters + set AppleScript's text item delimiters to ", " + set t1 to frontOfList as text + set formattedList to t1 & ", and " & lastName + set AppleScript's text item delimiters to tid + end if + return formattedList +end formatListOfNames + + +-- sometimes, an article has an author, sometimes it has more than one, sometimes there's no author +-- this function getAuthorStub() returns a string like " from Jean MacDonald " that can be used in crafting a message +-- about the current article. If there are no authors, it just returns a single space. +to getAuthorStub(authorNames) + try + if ((count authorNames) is greater than 0) then + return " from " & formatListOfNames(authorNames) & " " + end if + end try + return " " +end getAuthorStub + + + +-- Here's where the script starts + +-- first, get some relevant info out for NetNewsWire +tell application "NetNewsWire" + set articleUrl to the url of the current article + set articleTitle to the title of the current article + set authorNames to name of authors of the current article +end tell + + +-- then, prepare the message subject and message contents +set messageSubject to "From NetNewsWire to you: " & articleTitle +set myIntro to "Here's something" & getAuthorStub(authorNames) & "that I was reading on NetNewsWire: " +set messageContents to myIntro & return & return & articleUrl & return & return & getContentsOrHtml() + + +-- lastly, make a new outgoing message in Mail with the given subject and contents +tell application "Mail" + set m1 to make new outgoing message with properties {subject:messageSubject} + set content of m1 to messageContents +end tell \ No newline at end of file diff --git a/AppleScript/README.md b/AppleScript/README.md new file mode 100644 index 000000000..1b2dcbfe5 --- /dev/null +++ b/AppleScript/README.md @@ -0,0 +1,2 @@ +Sample AppleScript scripts go in this folder. + diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 2d749fd57..fb6a47caa 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -1,6 +1,6 @@ // // Account.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -18,6 +18,8 @@ import ArticlesDatabase import RSWeb import os.log +// Main thread only. + public extension Notification.Name { static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin") static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish") @@ -38,6 +40,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) + case search(String) +} + public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable { public struct UserInfoKey { @@ -218,7 +230,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } init?(dataFolder: String, type: AccountType, accountID: String, transport: Transport? = nil) { - switch type { case .onMyMac: self.delegate = LocalAccountDelegate() @@ -259,12 +270,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: nil) - pullObjectsFromDisk() DispatchQueue.main.async { @@ -273,7 +282,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate.accountDidInitialize(self) startingUp = false - } // MARK: - API @@ -295,7 +303,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, try CredentialsManager.storeCredentials(credentials, server: server) delegate.credentials = credentials - } public func retrieveCredentials() throws -> Credentials? { @@ -360,7 +367,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func importOPML(_ opmlFile: URL, completion: @escaping (Result) -> Void) { - guard !delegate.isOPMLImportInProgress else { completion(.failure(AccountError.opmlImportInProgress)) return @@ -386,7 +392,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, @discardableResult public func ensureFolder(with name: String) -> Folder? { - // TODO: support subfolders, maybe, some day if name.isEmpty { @@ -406,7 +411,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func ensureFolder(withFolderNames folderNames: [String]) -> Folder? { - // TODO: support subfolders, maybe, some day. // Since we don’t, just take the last name and make sure there’s a Folder. @@ -437,14 +441,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed { - let metadata = feedMetadata(feedURL: url, feedID: feedID) let feed = Feed(account: self, url: url, metadata: metadata) feed.name = name feed.homePageURL = homePageURL return feed - } public func removeFeed(_ feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { @@ -490,7 +492,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } func loadOPML(_ opmlDocument: RSOPMLDocument) { - guard let children = opmlDocument.children else { return } @@ -504,13 +505,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func updateUnreadCounts(for feeds: Set) { - if feeds.isEmpty { return } database.fetchUnreadCounts(for: feeds.feedIDs()) { (unreadCountDictionary) in - for feed in feeds { if let unreadCount = unreadCountDictionary[feed.feedID] { feed.unreadCount = unreadCount @@ -519,107 +518,63 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } } - public func fetchArticles(forArticleIDs articleIDs: Set) -> Set
{ - return database.fetchArticles(forArticleIDs: articleIDs) - } - - public func fetchArticles(for feed: Feed) -> Set
{ - - let articles = database.fetchArticles(for: feed.feedID) - validateUnreadCount(feed, articles) - return articles - } - - public func fetchUnreadArticles(for feed: Feed) -> Set
{ - - let articles = database.fetchUnreadArticles(for: Set([feed.feedID])) - validateUnreadCount(feed, articles) - return articles - } - - public func fetchUnreadArticles() -> Set
{ - - return fetchUnreadArticles(forContainer: self) - } - - public func fetchArticles(folder: Folder) -> Set
{ - - return fetchUnreadArticles(forContainer: folder) - } - - public func fetchUnreadArticles(forContainer container: Container) -> Set
{ - - 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
{ + 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
{ - - return database.fetchTodayArticles(for: flattenedFeeds().feedIDs()) - } - - public func fetchStarredArticles() -> Set
{ - - return database.fetchStarredArticles(for: flattenedFeeds().feedIDs()) - } - - public func fetchArticlesMatching(_ searchString: String) -> Set
{ - return database.fetchArticlesMatching(searchString, for: flattenedFeeds().feedIDs()) - } - - private func validateUnreadCount(_ feed: Feed, _ articles: Set
) { - - // articles must contain all the unread articles for the feed. - // The unread number should match the feed’s 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) { - let startOfToday = NSCalendar.startOfToday() database.fetchUnreadCount(for: flattenedFeeds().feedIDs(), since: startOfToday, callback: callback) } public func fetchUnreadCountForStarredArticles(_ callback: @escaping (Int) -> Void) { - database.fetchStarredAndUnreadCount(for: flattenedFeeds().feedIDs(), callback: callback) } - public func fetchUnreadArticleIDs() -> Set { - return database.fetchUnreadArticleIDs() + public func fetchUnreadArticleIDs(_ callback: @escaping (Set) -> Void) { + database.fetchUnreadArticleIDs(callback) + } + + public func fetchStarredArticleIDs(_ callback: @escaping (Set) -> Void) { + database.fetchStarredArticleIDs(callback) } - public func fetchStarredArticleIDs() -> Set { - return database.fetchStarredArticleIDs() - } - - public func fetchArticleIDsForStatusesWithoutArticles() -> Set { - return database.fetchArticleIDsForStatusesWithoutArticles() + public func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set) -> Void) { + database.fetchArticleIDsForStatusesWithoutArticles(callback) } public func opmlDocument() -> String { @@ -672,9 +627,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } func update(_ feed: Feed, parsedItems: Set, defaultRead: Bool = false, _ completion: @escaping (() -> Void)) { - database.update(feedID: feed.feedID, parsedItems: parsedItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in - var userInfo = [String: Any]() if let newArticles = newArticles, !newArticles.isEmpty { self.updateUnreadCounts(for: Set([feed])) @@ -689,14 +642,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo) } - } + @discardableResult func update(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { - // Returns set of Articles whose statuses did change. - - guard let updatedStatuses = database.mark(articles, statusKey: statusKey, flag: flag) else { + guard !articles.isEmpty, let updatedStatuses = database.mark(articles, statusKey: statusKey, flag: flag) else { return nil } @@ -705,16 +656,18 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, noteStatusesForArticlesDidChange(updatedArticles) return updatedArticles - } func ensureStatuses(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool) { - database.ensureStatuses(articleIDs, statusKey, flag) + if !articleIDs.isEmpty { + database.ensureStatuses(articleIDs, statusKey, flag) + } } // MARK: - Container public func flattenedFeeds() -> Set { + assert(Thread.isMainThread) if flattenedFeedsNeedUpdate { updateFlattenedFeeds() } @@ -748,7 +701,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // MARK: - Debug public func debugDropConditionalGetInfo() { - #if DEBUG flattenedFeeds().forEach{ $0.debugDropConditionalGetInfo() } #endif @@ -767,7 +719,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // MARK: - Notifications @objc func downloadProgressDidChange(_ note: Notification) { - guard let noteObject = note.object as? DownloadProgress, noteObject === refreshProgress else { return } @@ -783,14 +734,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } @objc func batchUpdateDidPerform(_ note: Notification) { - flattenedFeedsNeedUpdate = true rebuildFeedDictionaries() updateUnreadCount() } @objc func childrenDidChange(_ note: Notification) { - guard let object = note.object else { return } @@ -804,14 +753,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } @objc func displayNameDidChange(_ note: Notification) { - if let folder = note.object as? Folder, folder.account === self { structureDidChange() } } @objc func saveToDiskIfNeeded() { - if dirty && !isDeleted { saveToDisk() } @@ -838,7 +785,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // MARK: - Equatable public class func ==(lhs: Account, rhs: Account) -> Bool { - return lhs === rhs } } @@ -864,6 +810,131 @@ extension Account: FeedMetadataDelegate { } } +// MARK: - Fetching (Private) + +private extension Account { + + func fetchStarredArticles() -> Set
{ + return database.fetchStarredArticles(flattenedFeeds().feedIDs()) + } + + func fetchStarredArticlesAsync(_ callback: @escaping ArticleSetBlock) { + database.fetchedStarredArticlesAsync(flattenedFeeds().feedIDs(), callback) + } + + func fetchUnreadArticles() -> Set
{ + return fetchUnreadArticles(forContainer: self) + } + + func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { + fetchUnreadArticlesAsync(forContainer: self, callback) + } + + func fetchTodayArticles() -> Set
{ + return database.fetchTodayArticles(flattenedFeeds().feedIDs()) + } + + func fetchTodayArticlesAsync(_ callback: @escaping ArticleSetBlock) { + database.fetchTodayArticlesAsync(flattenedFeeds().feedIDs(), callback) + } + + func fetchArticles(folder: Folder) -> Set
{ + return fetchUnreadArticles(forContainer: folder) + } + + func fetchArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) { + fetchUnreadArticlesAsync(forContainer: folder, callback) + } + + func fetchArticles(feed: Feed) -> Set
{ + 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
{ + return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs()) + } + + func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) { + database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback) + } + + func fetchArticles(articleIDs: Set) -> Set
{ + return database.fetchArticles(articleIDs: articleIDs) + } + + func fetchArticlesAsync(articleIDs: Set, _ callback: @escaping ArticleSetBlock) { + return database.fetchArticlesAsync(articleIDs: articleIDs, callback) + } + + func fetchUnreadArticles(feed: Feed) -> Set
{ + let articles = database.fetchUnreadArticles(Set([feed.feedID])) + validateUnreadCount(feed, articles) + return articles + } + + func fetchUnreadArticlesAsync(for feed: Feed, callback: @escaping (Set
) -> Void) { + // database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in + // self?.validateUnreadCount(feed, articles) + // callback(articles) + // } + } + + + func fetchUnreadArticles(forContainer container: Container) -> Set
{ + 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, _ articles: Set
) { + // 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] + for article in articles where !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
) { + // articles must contain all the unread articles for the feed. + // The unread number should match the feed’s 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 { @@ -930,11 +1001,9 @@ private extension Account { BatchUpdate.shared.perform { loadOPMLItems(children, parentFolder: nil) } - } func saveToDisk() { - dirty = false let opmlDocumentString = opmlDocument() @@ -1032,7 +1101,6 @@ private extension Account { } func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) { - var feedsToAdd = Set() items.forEach { (item) in @@ -1082,7 +1150,6 @@ private extension Account { } func noteStatusesForArticlesDidChange(_ articles: Set
) { - let feeds = Set(articles.compactMap { $0.feed }) let statuses = Set(articles.map { $0.status }) @@ -1094,10 +1161,9 @@ private extension Account { } func fetchAllUnreadCounts() { - fetchingAllUnreadCounts = true - database.fetchAllNonZeroUnreadCounts { (unreadCountDictionary) in + database.fetchAllNonZeroUnreadCounts { (unreadCountDictionary) in if unreadCountDictionary.isEmpty { self.fetchingAllUnreadCounts = false self.updateUnreadCount() @@ -1106,7 +1172,6 @@ private extension Account { } self.flattenedFeeds().forEach{ (feed) in - // When the unread count is zero, it won’t appear in unreadCountDictionary. if let unreadCount = unreadCountDictionary[feed.feedID] { @@ -1128,10 +1193,8 @@ private extension Account { extension Account { public func existingFeed(with feedID: String) -> Feed? { - return idToFeedDictionary[feedID] } - } // MARK: - OPMLRepresentable @@ -1139,7 +1202,6 @@ extension Account { extension Account: OPMLRepresentable { public func OPMLString(indentLevel: Int) -> String { - var s = "" for feed in topLevelFeeds { s += feed.OPMLString(indentLevel: indentLevel + 1) diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index cc657f770..b4af6e7f0 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -1,6 +1,6 @@ // // AccountDelegate.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 9/16/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. diff --git a/Frameworks/Account/AccountError.swift b/Frameworks/Account/AccountError.swift index 0aeb109a1..090cb268d 100644 --- a/Frameworks/Account/AccountError.swift +++ b/Frameworks/Account/AccountError.swift @@ -1,6 +1,6 @@ // // AccountError.swift -// Account +// NetNewsWire // // Created by Maurice Parker on 5/26/19. // Copyright © 2019 Ranchero Software, LLC. All rights reserved. @@ -19,9 +19,9 @@ public enum AccountError: LocalizedError { public var errorDescription: String? { switch self { case .createErrorNotFound: - return NSLocalizedString("The feed couldn't be found and can't be added.", comment: "Not found") + return NSLocalizedString("The feed couldn’t be found and can’t be added.", comment: "Not found") case .createErrorAlreadySubscribed: - return NSLocalizedString("You are already subscribed to this feed and can't add it again.", comment: "Already subscribed") + return NSLocalizedString("You are already subscribed to this feed and can’t add it again.", comment: "Already subscribed") case .opmlImportInProgress: return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running") case .wrappedError(let error, let account): @@ -65,5 +65,4 @@ public enum AccountError: LocalizedError { let localizedText = NSLocalizedString("An error occurred while processing the \"%@\" account: %@", comment: "Unknown error") return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String } - } diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 6d07cdaf4..0b5e71b20 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -10,12 +10,11 @@ import Foundation import RSCore import Articles -public extension Notification.Name { - static let AccountsDidChange = Notification.Name(rawValue: "AccountsDidChange") -} +// Main thread only. -private let defaultAccountFolderName = "OnMyMac" -private let defaultAccountIdentifier = "OnMyMac" +public extension Notification.Name { + static let AccountsDidChange = Notification.Name("AccountsDidChange") +} public final class AccountManager: UnreadCountProvider { @@ -25,6 +24,9 @@ public final class AccountManager: UnreadCountProvider { private let accountsFolder = RSDataSubfolder(nil, "Accounts")! private var accountsDictionary = [String: Account]() + private let defaultAccountFolderName = "OnMyMac" + private let defaultAccountIdentifier = "OnMyMac" + public var isUnreadCountsInitialized: Bool { for account in activeAccounts { if !account.isUnreadCountsInitialized { @@ -51,6 +53,7 @@ public final class AccountManager: UnreadCountProvider { } public var activeAccounts: [Account] { + assert(Thread.isMainThread) return Array(accountsDictionary.values.filter { $0.isActive }) } @@ -73,9 +76,7 @@ public final class AccountManager: UnreadCountProvider { } public init() { - // The local "On My Mac" account must always exist, even if it's empty. - let localAccountFolder = (accountsFolder as NSString).appendingPathComponent("OnMyMac") do { try FileManager.default.createDirectory(atPath: localAccountFolder, withIntermediateDirectories: true, attributes: nil) @@ -98,10 +99,9 @@ public final class AccountManager: UnreadCountProvider { } } - // MARK: API + // MARK: - API public func createAccount(type: AccountType) -> Account { - let accountID = UUID().uuidString let accountFolder = (accountsFolder as NSString).appendingPathComponent("\(type.rawValue)_\(accountID)") @@ -121,7 +121,6 @@ public final class AccountManager: UnreadCountProvider { } public func deleteAccount(_ account: Account) { - guard !account.refreshInProgress else { return } @@ -139,16 +138,13 @@ public final class AccountManager: UnreadCountProvider { updateUnreadCount() NotificationCenter.default.post(name: .AccountsDidChange, object: self) - } public func existingAccount(with accountID: String) -> Account? { - return accountsDictionary[accountID] } public func refreshAll(errorHandler: @escaping (Error) -> Void) { - activeAccounts.forEach { account in account.refreshAll() { result in switch result { @@ -159,7 +155,6 @@ public final class AccountManager: UnreadCountProvider { } } } - } public func syncArticleStatusAll(completion: (() -> Void)? = nil) { @@ -178,7 +173,6 @@ public final class AccountManager: UnreadCountProvider { } public func anyAccountHasAtLeastOneFeed() -> Bool { - for account in activeAccounts { if account.hasAtLeastOneFeed() { return true @@ -189,7 +183,6 @@ public final class AccountManager: UnreadCountProvider { } public func anyAccountHasFeedWithURL(_ urlString: String) -> Bool { - for account in activeAccounts { if let _ = account.existingFeed(withURL: urlString) { return true @@ -197,16 +190,42 @@ public final class AccountManager: UnreadCountProvider { } return false } - - func updateUnreadCount() { - unreadCount = calculateUnreadCount(activeAccounts) + // MARK: - Fetching Articles + + // These fetch articles from active accounts and return a merged Set
. + + public func fetchArticles(_ fetchType: FetchType) -> Set
{ + precondition(Thread.isMainThread) + + var articles = Set
() + for account in activeAccounts { + articles.formUnion(account.fetchArticles(fetchType)) + } + return articles } - - // MARK: Notifications + + public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) { + precondition(Thread.isMainThread) + + var allFetchedArticles = Set
() + 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) { - guard let _ = notification.object as? Account else { return } @@ -216,15 +235,21 @@ public final class AccountManager: UnreadCountProvider { @objc func accountStateDidChange(_ notification: Notification) { updateUnreadCount() } - - // MARK: Private +} - private func loadAccount(_ accountSpecifier: AccountSpecifier) -> Account? { +// MARK: - Private + +private extension AccountManager { + + func updateUnreadCount() { + unreadCount = calculateUnreadCount(activeAccounts) + } + + func loadAccount(_ accountSpecifier: AccountSpecifier) -> Account? { return Account(dataFolder: accountSpecifier.folderPath, type: accountSpecifier.type, accountID: accountSpecifier.identifier) } - private func loadAccount(_ filename: String) -> Account? { - + func loadAccount(_ filename: String) -> Account? { let folderPath = (accountsFolder as NSString).appendingPathComponent(filename) if let accountSpecifier = AccountSpecifier(folderPath: folderPath) { return loadAccount(accountSpecifier) @@ -232,8 +257,7 @@ public final class AccountManager: UnreadCountProvider { return nil } - private func readAccountsFromDisk() { - + func readAccountsFromDisk() { var filenames: [String]? do { @@ -245,7 +269,6 @@ public final class AccountManager: UnreadCountProvider { } filenames?.forEach { (oneFilename) in - guard oneFilename != defaultAccountFolderName else { return } @@ -255,12 +278,10 @@ public final class AccountManager: UnreadCountProvider { } } - private func sortByName(_ accounts: [Account]) -> [Account] { - + func sortByName(_ accounts: [Account]) -> [Account] { // LocalAccount is first. return accounts.sorted { (account1, account2) -> Bool in - if account1 === defaultAccount { return true } @@ -272,13 +293,6 @@ public final class AccountManager: UnreadCountProvider { } } -private let accountDataFileName = "AccountData.plist" - -private func accountFilePathWithFolder(_ folderPath: String) -> String { - - return NSString(string: folderPath).appendingPathComponent(accountDataFileName) -} - private struct AccountSpecifier { let type: AccountType @@ -287,8 +301,8 @@ private struct AccountSpecifier { let folderName: String let dataFilePath: String - init?(folderPath: String) { + init?(folderPath: String) { if !FileManager.default.rs_fileIsFolder(folderPath) { return nil } @@ -300,18 +314,21 @@ private struct AccountSpecifier { let nameComponents = name.components(separatedBy: "_") - guard nameComponents.count == 2, let rawType = Int(nameComponents[0]), let acctType = AccountType(rawValue: rawType) else { + guard nameComponents.count == 2, let rawType = Int(nameComponents[0]), let accountType = AccountType(rawValue: rawType) else { return nil } self.folderPath = folderPath self.folderName = name - self.type = acctType + self.type = accountType self.identifier = nameComponents[1] - self.dataFilePath = accountFilePathWithFolder(self.folderPath) - + self.dataFilePath = AccountSpecifier.accountFilePathWithFolder(self.folderPath) + } + + private static let accountDataFileName = "AccountData.plist" + + private static func accountFilePathWithFolder(_ folderPath: String) -> String { + return NSString(string: folderPath).appendingPathComponent(accountDataFileName) } } - - diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift index a741bfb12..ff05fd734 100644 --- a/Frameworks/Account/AccountMetadata.swift +++ b/Frameworks/Account/AccountMetadata.swift @@ -77,5 +77,4 @@ final class AccountMetadata: Codable { func valueDidChange(_ key: CodingKeys) { delegate?.valueDidChange(self, key: key) } - } diff --git a/Frameworks/Account/ArticleFetcher.swift b/Frameworks/Account/ArticleFetcher.swift index 725d897b3..a4f6b3978 100644 --- a/Frameworks/Account/ArticleFetcher.swift +++ b/Frameworks/Account/ArticleFetcher.swift @@ -1,6 +1,6 @@ // // ArticleFetcher.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 2/4/18. // Copyright © 2018 Ranchero Software, LLC. All rights reserved. @@ -12,44 +12,59 @@ import Articles public protocol ArticleFetcher { func fetchArticles() -> Set
+ func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) func fetchUnreadArticles() -> Set
+ func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) } extension Feed: ArticleFetcher { public func fetchArticles() -> Set
{ + return account?.fetchArticles(.feed(self)) ?? Set
() + } + public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) { guard let account = account else { assertionFailure("Expected feed.account, but got nil.") - return Set
() + callback(Set
()) + return } - return account.fetchArticles(for: self) + account.fetchArticlesAsync(.feed(self), callback) } public func fetchUnreadArticles() -> Set
{ + preconditionFailure("feed.fetchUnreadArticles is unused.") + } - guard let account = account else { - assertionFailure("Expected feed.account, but got nil.") - return Set
() - } - return account.fetchUnreadArticles(for: self) + public func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { + preconditionFailure("feed.fetchUnreadArticlesAsync is unused.") } } extension Folder: ArticleFetcher { public func fetchArticles() -> Set
{ - return fetchUnreadArticles() } - public func fetchUnreadArticles() -> Set
{ + public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) { + fetchUnreadArticlesAsync(callback) + } + public func fetchUnreadArticles() -> Set
{ guard let account = account else { assertionFailure("Expected folder.account, but got nil.") return Set
() } + 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
()) + return + } + account.fetchArticlesAsync(.unreadForFolder(self), callback) } } diff --git a/Frameworks/Account/CombinedRefreshProgress.swift b/Frameworks/Account/CombinedRefreshProgress.swift index f9f5cb52d..a334351c9 100644 --- a/Frameworks/Account/CombinedRefreshProgress.swift +++ b/Frameworks/Account/CombinedRefreshProgress.swift @@ -1,6 +1,6 @@ // // CombinedRefreshProgress.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 10/7/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -20,7 +20,6 @@ public struct CombinedRefreshProgress { public let isComplete: Bool init(numberOfTasks: Int, numberRemaining: Int, numberCompleted: Int) { - self.numberOfTasks = max(numberOfTasks, 0) self.numberRemaining = max(numberRemaining, 0) self.numberCompleted = max(numberCompleted, 0) @@ -28,7 +27,6 @@ public struct CombinedRefreshProgress { } public init(downloadProgressArray: [DownloadProgress]) { - var numberOfTasks = 0 var numberRemaining = 0 var numberCompleted = 0 diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index bd3092894..c37cf2bfe 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -76,7 +76,6 @@ public extension Container { } func flattenedFeeds() -> Set { - var feeds = Set() feeds.formUnion(topLevelFeeds) if let folders = folders { diff --git a/Frameworks/Account/ContainerPath.swift b/Frameworks/Account/ContainerPath.swift index deb70e7e3..ea48838f2 100644 --- a/Frameworks/Account/ContainerPath.swift +++ b/Frameworks/Account/ContainerPath.swift @@ -1,6 +1,6 @@ // // ContainerPath.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 11/4/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -22,7 +22,6 @@ public struct ContainerPath { // folders should be from top-level down, as in ["Cats", "Tabbies"] public init(account: Account, folders: [Folder]) { - self.account = account self.names = folders.map { $0.nameForDisplay } self.isTopLevel = folders.isEmpty @@ -31,7 +30,6 @@ public struct ContainerPath { } public func resolveContainer() -> Container? { - // The only time it should fail is if the account no longer exists. // Otherwise the worst-case scenario is that it will create Folders if needed. diff --git a/Frameworks/Account/DataExtensions.swift b/Frameworks/Account/DataExtensions.swift index cffdf1c62..bee0adaa7 100644 --- a/Frameworks/Account/DataExtensions.swift +++ b/Frameworks/Account/DataExtensions.swift @@ -1,6 +1,6 @@ // // DataExtensions.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 10/7/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. diff --git a/Frameworks/Account/Feed.swift b/Frameworks/Account/Feed.swift index c0b3cb9cd..f18c2c6dd 100644 --- a/Frameworks/Account/Feed.swift +++ b/Frameworks/Account/Feed.swift @@ -1,6 +1,6 @@ // // Feed.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. diff --git a/Frameworks/Account/FeedFinder/FeedFinder.swift b/Frameworks/Account/FeedFinder/FeedFinder.swift index c6c2f660e..479598248 100644 --- a/Frameworks/Account/FeedFinder/FeedFinder.swift +++ b/Frameworks/Account/FeedFinder/FeedFinder.swift @@ -1,6 +1,6 @@ // // FeedFinder.swift -// FeedFinder +// NetNewsWire // // Created by Brent Simmons on 8/2/16. // Copyright © 2016 Ranchero Software, LLC. All rights reserved. @@ -14,9 +14,7 @@ import RSCore class FeedFinder { static func find(url: URL, completion: @escaping (Result, Error>) -> Void) { - downloadUsingCache(url) { (data, response, error) in - if response?.forcedStatusCode == 404 { completion(.failure(AccountError.createErrorNotFound)) return @@ -49,17 +47,13 @@ class FeedFinder { } FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion) - } - } - } private extension FeedFinder { static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) { - // If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source. if let existingFeedSpecifier = feedSpecifiers[feedSpecifier.urlString] { @@ -72,7 +66,6 @@ private extension FeedFinder { } static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result, Error>) -> Void) { - // Feeds in the section we automatically assume are feeds. // If there are none from the section, // then possible feeds in section are downloaded individually @@ -99,16 +92,17 @@ private extension FeedFinder { if didFindFeedInHTMLHead { completion(.success(Set(feedSpecifiers.values))) return - } else if feedSpecifiersToDownload.isEmpty { + } + else if feedSpecifiersToDownload.isEmpty { completion(.failure(AccountError.createErrorNotFound)) return - } else { + } + else { downloadFeedSpecifiers(feedSpecifiersToDownload, feedSpecifiers: feedSpecifiers, completion: completion) } } static func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set { - let parserData = ParserData(url: urlString, data: htmlData) var feedSpecifiers = HTMLFeedFinder(parserData: parserData).feedSpecifiers @@ -139,7 +133,6 @@ private extension FeedFinder { let group = DispatchGroup() for downloadFeedSpecifier in downloadFeedSpecifiers { - guard let url = URL(string: downloadFeedSpecifier.urlString) else { continue } @@ -159,12 +152,10 @@ private extension FeedFinder { group.notify(queue: DispatchQueue.main) { completion(.success(Set(resultFeedSpecifiers.values))) } - } static func isFeed(_ data: Data, _ urlString: String) -> Bool { let parserData = ParserData(url: urlString, data: data) return FeedParser.canParse(parserData) } - } diff --git a/Frameworks/Account/FeedFinder/FeedSpecifier.swift b/Frameworks/Account/FeedFinder/FeedSpecifier.swift index f90e956fe..ebcfab616 100644 --- a/Frameworks/Account/FeedFinder/FeedSpecifier.swift +++ b/Frameworks/Account/FeedFinder/FeedSpecifier.swift @@ -1,6 +1,6 @@ // // FeedSpecifier.swift -// FeedFinder +// NetNewsWire // // Created by Brent Simmons on 8/7/16. // Copyright © 2016 Ranchero Software, LLC. All rights reserved. @@ -11,11 +11,9 @@ import Foundation struct FeedSpecifier: Hashable { enum Source: Int { - case UserEntered = 0, HTMLHead, HTMLLink func equalToOrBetterThan(_ otherSource: Source) -> Bool { - return self.rawValue <= otherSource.rawValue } } @@ -28,7 +26,6 @@ struct FeedSpecifier: Hashable { } func feedSpecifierByMerging(_ feedSpecifier: FeedSpecifier) -> FeedSpecifier { - // Take the best data (non-nil title, better source) to create a new feed specifier; let mergedTitle = title ?? feedSpecifier.title @@ -38,7 +35,6 @@ struct FeedSpecifier: Hashable { } public static func bestFeed(in feedSpecifiers: Set) -> FeedSpecifier? { - if feedSpecifiers.isEmpty { return nil } @@ -64,7 +60,6 @@ struct FeedSpecifier: Hashable { private extension FeedSpecifier { func calculatedScore() -> Int { - var score = 0 if source == .UserEntered { diff --git a/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift b/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift index fbe7f8548..ff4baa2a2 100644 --- a/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift +++ b/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift @@ -1,6 +1,6 @@ // // HTMLFeedFinder.swift -// FeedFinder +// NetNewsWire // // Created by Brent Simmons on 8/7/16. // Copyright © 2016 Ranchero Software, LLC. All rights reserved. @@ -20,7 +20,6 @@ class HTMLFeedFinder { private var feedSpecifiersDictionary = [String: FeedSpecifier]() init(parserData: ParserData) { - let metadata = RSHTMLMetadataParser.htmlMetadata(with: parserData) for oneFeedLink in metadata.feedLinks { @@ -46,7 +45,6 @@ class HTMLFeedFinder { private extension HTMLFeedFinder { func addFeedSpecifier(_ feedSpecifier: FeedSpecifier) { - // If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source. if let existingFeedSpecifier = feedSpecifiersDictionary[feedSpecifier.urlString] { @@ -59,7 +57,6 @@ private extension HTMLFeedFinder { } func urlStringMightBeFeed(_ urlString: String) -> Bool { - let massagedURLString = urlString.replacingOccurrences(of: "buzzfeed", with: "_") for oneMatch in feedURLWordsToMatch { @@ -73,7 +70,6 @@ private extension HTMLFeedFinder { } func linkMightBeFeed(_ link: RSHTMLLink) -> Bool { - if let linkURLString = link.urlString, urlStringMightBeFeed(linkURLString) { return true } diff --git a/Frameworks/Account/FeedMetadata.swift b/Frameworks/Account/FeedMetadata.swift index 6215c2699..cac1f4e6c 100644 --- a/Frameworks/Account/FeedMetadata.swift +++ b/Frameworks/Account/FeedMetadata.swift @@ -1,6 +1,6 @@ // // FeedMetadata.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 3/12/19. // Copyright © 2019 Ranchero Software, LLC. All rights reserved. @@ -128,5 +128,4 @@ final class FeedMetadata: Codable { func valueDidChange(_ key: CodingKeys) { delegate?.valueDidChange(self, key: key) } - } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index a0a7957dd..beac0674e 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -602,8 +602,8 @@ private extension FeedbinAccountDelegate { } func syncFolders(_ account: Account, _ tags: [FeedbinTag]?) { - guard let tags = tags else { return } + assert(Thread.isMainThread) os_log(.debug, log: log, "Syncing folders with %ld tags.", tags.count) @@ -613,13 +613,11 @@ private extension FeedbinAccountDelegate { if let folders = account.folders { folders.forEach { folder in if !tagNames.contains(folder.name ?? "") { - DispatchQueue.main.sync { - for feed in folder.topLevelFeeds { - account.addFeed(feed) - clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") - } - account.removeFolder(folder) + for feed in folder.topLevelFeeds { + account.addFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") } + account.removeFolder(folder) } } } @@ -635,9 +633,7 @@ private extension FeedbinAccountDelegate { // Make any folders Feedbin has, but we don't tagNames.forEach { tagName in if !folderNames.contains(tagName) { - DispatchQueue.main.sync { - _ = account.ensureFolder(with: tagName) - } + _ = account.ensureFolder(with: tagName) } } @@ -691,7 +687,8 @@ private extension FeedbinAccountDelegate { func syncFeeds(_ account: Account, _ subscriptions: [FeedbinSubscription]?) { guard let subscriptions = subscriptions else { return } - + assert(Thread.isMainThread) + os_log(.debug, log: log, "Syncing feeds with %ld subscriptions.", subscriptions.count) let subFeedIds = subscriptions.map { String($0.feedID) } @@ -701,9 +698,7 @@ private extension FeedbinAccountDelegate { for folder in folders { for feed in folder.topLevelFeeds { if !subFeedIds.contains(feed.feedID) { - DispatchQueue.main.sync { - folder.removeFeed(feed) - } + folder.removeFeed(feed) } } } @@ -711,9 +706,7 @@ private extension FeedbinAccountDelegate { for feed in account.topLevelFeeds { if !subFeedIds.contains(feed.feedID) { - DispatchQueue.main.sync { - account.removeFeed(feed) - } + account.removeFeed(feed) } } @@ -722,27 +715,24 @@ private extension FeedbinAccountDelegate { let subFeedId = String(subscription.feedID) - DispatchQueue.main.sync { - if let feed = account.idToFeedDictionary[subFeedId] { - feed.name = subscription.name - // If the name has been changed on the server remove the locally edited name - feed.editedName = nil - feed.homePageURL = subscription.homePageURL - feed.subscriptionID = String(subscription.subscriptionID) - } else { - let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL) - feed.subscriptionID = String(subscription.subscriptionID) - account.addFeed(feed) - } + if let feed = account.idToFeedDictionary[subFeedId] { + feed.name = subscription.name + // If the name has been changed on the server remove the locally edited name + feed.editedName = nil + feed.homePageURL = subscription.homePageURL + feed.subscriptionID = String(subscription.subscriptionID) + } else { + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL) + feed.subscriptionID = String(subscription.subscriptionID) + account.addFeed(feed) } - } - } func syncTaggings(_ account: Account, _ taggings: [FeedbinTagging]?) { guard let taggings = taggings else { return } + assert(Thread.isMainThread) os_log(.debug, log: log, "Syncing taggings with %ld taggings.", taggings.count) @@ -776,11 +766,9 @@ private extension FeedbinAccountDelegate { // Move any feeds not in the folder to the account for feed in folder.topLevelFeeds { if !taggingFeedIDs.contains(feed.feedID) { - DispatchQueue.main.sync { - folder.removeFeed(feed) - clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") - account.addFeed(feed) - } + folder.removeFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + account.addFeed(feed) } } @@ -793,10 +781,8 @@ private extension FeedbinAccountDelegate { guard let feed = account.idToFeedDictionary[taggingFeedID] else { continue } - DispatchQueue.main.sync { - saveFolderRelationship(for: feed, withFolderName: folderName, id: String(tagging.taggingID)) - folder.addFeed(feed) - } + saveFolderRelationship(for: feed, withFolderName: folderName, id: String(tagging.taggingID)) + folder.addFeed(feed) } } @@ -805,14 +791,11 @@ private extension FeedbinAccountDelegate { let taggedFeedIDs = Set(taggings.map { String($0.feedID) }) // Remove all feeds from the account container that have a tag - DispatchQueue.main.sync { - for feed in account.topLevelFeeds { - if taggedFeedIDs.contains(feed.feedID) { - account.removeFeed(feed) - } + for feed in account.topLevelFeeds { + if taggedFeedIDs.contains(feed.feedID) { + account.removeFeed(feed) } } - } func syncFavicons(_ account: Account, _ icons: [FeedbinIcon]?) { @@ -826,14 +809,11 @@ private extension FeedbinAccountDelegate { for feed in account.flattenedFeeds() { for (key, value) in iconDict { if feed.homePageURL?.contains(key) ?? false { - DispatchQueue.main.sync { - feed.faviconURL = value - } + feed.faviconURL = value break } } } - } @@ -1023,33 +1003,29 @@ private extension FeedbinAccountDelegate { } func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) { - os_log(.debug, log: log, "Refreshing missing articles...") - let articleIDs = Array(account.fetchArticleIDsForStatusesWithoutArticles()) - let group = DispatchGroup() - - let chunkedArticleIDs = articleIDs.chunked(into: 100) - - for chunk in chunkedArticleIDs { - - group.enter() - caller.retrieveEntries(articleIDs: chunk) { result in - - switch result { - case .success(let entries): - - self.processEntries(account: account, entries: entries) { + + account.fetchArticleIDsForStatusesWithoutArticles { (fetchedArticleIDs) in + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 100) + for chunk in chunkedArticleIDs { + group.enter() + self.caller.retrieveEntries(articleIDs: chunk) { result in + + switch result { + case .success(let entries): + + self.processEntries(account: account, entries: entries) { + group.leave() + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) group.leave() } - - case .failure(let error): - os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) - group.leave() } - } - } group.notify(queue: DispatchQueue.main) { @@ -1057,7 +1033,6 @@ private extension FeedbinAccountDelegate { os_log(.debug, log: self.log, "Done refreshing missing articles.") completion() } - } func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) { @@ -1131,89 +1106,65 @@ private extension FeedbinAccountDelegate { } func syncArticleReadState(account: Account, articleIDs: [Int]?) { - guard let articleIDs = articleIDs else { return } let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } ) - let currentUnreadArticleIDs = account.fetchUnreadArticleIDs() - - // Mark articles as unread - let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs) - let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs) - DispatchQueue.main.async { - _ = account.update(markUnreadArticles, statusKey: .read, flag: false) - } - - // Save any unread statuses for articles we haven't yet received - let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) - let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) - if !missingUnreadArticleIDs.isEmpty { - DispatchQueue.main.async { + account.fetchUnreadArticleIDs { (currentUnreadArticleIDs) in + // Mark articles as unread + let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaUnreadArticleIDs)) { (markUnreadArticles) in + account.update(markUnreadArticles, statusKey: .read, flag: false) + + // Save any unread statuses for articles we haven't yet received + let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) + let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) account.ensureStatuses(missingUnreadArticleIDs, .read, false) } - } - - // Mark articles as read - let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) - let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) - DispatchQueue.main.async { - _ = account.update(markReadArticles, statusKey: .read, flag: true) - } - - // Save any read statuses for articles we haven't yet received - let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) - let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) - if !missingReadArticleIDs.isEmpty { - DispatchQueue.main.async { + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaReadArticleIDs)) { (markReadArticles) in + account.update(markReadArticles, statusKey: .read, flag: true) + + // Save any read statuses for articles we haven't yet received + let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) + let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) account.ensureStatuses(missingReadArticleIDs, .read, true) } } - } func syncArticleStarredState(account: Account, articleIDs: [Int]?) { - guard let articleIDs = articleIDs else { return } let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } ) - let currentStarredArticleIDs = account.fetchStarredArticleIDs() - - // Mark articles as starred - let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs) - let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs) - DispatchQueue.main.async { - _ = account.update(markStarredArticles, statusKey: .starred, flag: true) - } - - // Save any starred statuses for articles we haven't yet received - let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) - let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) - if !missingStarredArticleIDs.isEmpty { - DispatchQueue.main.async { + account.fetchStarredArticleIDs { (currentStarredArticleIDs) in + // Mark articles as starred + let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaStarredArticleIDs)) { (markStarredArticles) in + account.update(markStarredArticles, statusKey: .starred, flag: true) + + // Save any starred statuses for articles we haven't yet received + let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) + let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) account.ensureStatuses(missingStarredArticleIDs, .starred, true) } - } - - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) - let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) - DispatchQueue.main.async { - _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) - } - - // Save any unstarred statuses for articles we haven't yet received - let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) - let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) - if !missingUnstarredArticleIDs.isEmpty { - DispatchQueue.main.async { + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaUnstarredArticleIDs)) { (markUnstarredArticles) in + account.update(markUnstarredArticles, statusKey: .starred, flag: false) + + // Save any unstarred statuses for articles we haven't yet received + let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) + let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) account.ensureStatuses(missingUnstarredArticleIDs, .starred, false) } } - } func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index f2ee0551f..6d164a28c 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -1,6 +1,6 @@ // // Folder.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -52,7 +52,6 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun // MARK: - Init init(account: Account, name: String?) { - self.account = account self.name = name @@ -67,7 +66,6 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun // MARK: - Notifications @objc func unreadCountDidChange(_ note: Notification) { - if let object = note.object { if objectIsChild(object as AnyObject) { updateUnreadCount() @@ -76,7 +74,6 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun } @objc func childrenDidChange(_ note: Notification) { - updateUnreadCount() } @@ -114,7 +111,6 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun // MARK: - Equatable static public func ==(lhs: Folder, rhs: Folder) -> Bool { - return lhs === rhs } } @@ -141,7 +137,6 @@ private extension Folder { extension Folder: OPMLRepresentable { public func OPMLString(indentLevel: Int) -> String { - let escapedTitle = nameForDisplay.rs_stringByEscapingSpecialXMLCharacters() var s = "\n" s = s.rs_string(byPrependingNumberOfTabs: indentLevel) diff --git a/Frameworks/Account/LocalAccount/InitialFeedDownloader.swift b/Frameworks/Account/LocalAccount/InitialFeedDownloader.swift index 41f6c6ca5..4e7ebda05 100644 --- a/Frameworks/Account/LocalAccount/InitialFeedDownloader.swift +++ b/Frameworks/Account/LocalAccount/InitialFeedDownloader.swift @@ -15,7 +15,6 @@ struct InitialFeedDownloader { static func download(_ url: URL,_ completionHandler: @escaping (_ parsedFeed: ParsedFeed?) -> Void) { downloadUsingCache(url) { (data, response, error) in - guard let data = data else { completionHandler(nil) return diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index b051f1f9b..af5db8134 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -1,6 +1,6 @@ // // LocalAccountDelegate.swift -// Account +// NetNewsWire // // Created by Brent Simmons on 9/16/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -48,7 +48,6 @@ final class LocalAccountDelegate: AccountDelegate { } func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { - var fileData: Data? do { @@ -88,7 +87,6 @@ final class LocalAccountDelegate: AccountDelegate { } func createFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - guard let url = URL(string: urlString) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return @@ -99,8 +97,6 @@ final class LocalAccountDelegate: AccountDelegate { switch result { case .success(let feedSpecifiers): - - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { self.refreshProgress.completeTask() @@ -117,7 +113,6 @@ final class LocalAccountDelegate: AccountDelegate { let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() if let parsedFeed = parsedFeed { @@ -199,5 +194,4 @@ final class LocalAccountDelegate: AccountDelegate { static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { return completion(.success(nil)) } - } diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index fbdf1d1e6..90718a233 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -1,6 +1,6 @@ // // LocalAccountRefresher.swift -// LocalAccount +// NetNewsWire // // Created by Brent Simmons on 9/6/16. // Copyright © 2016 Ranchero Software, LLC. All rights reserved. @@ -23,7 +23,6 @@ final class LocalAccountRefresher { } public func refreshFeeds(_ feeds: Set) { - downloadSession.downloadObjects(feeds as NSSet) } } @@ -33,11 +32,9 @@ final class LocalAccountRefresher { extension LocalAccountRefresher: DownloadSessionDelegate { func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject representedObject: AnyObject) -> URLRequest? { - guard let feed = representedObject as? Feed else { return nil } - guard let url = URL(string: feed.url) else { return nil } @@ -51,7 +48,6 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?) { - guard let feed = representedObject as? Feed, !data.isEmpty else { return } @@ -63,18 +59,15 @@ extension LocalAccountRefresher: DownloadSessionDelegate { let dataHash = (data as NSData).rs_md5HashString() if dataHash == feed.contentHash { -// print("Hashed content of \(feed.url) has not changed.") return } let parserData = ParserData(url: feed.url, data: data) FeedParser.parse(parserData) { (parsedFeed, error) in - guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else { return } account.update(feed, with: parsedFeed) { - if let httpResponse = response as? HTTPURLResponse { feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse) } @@ -85,7 +78,6 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool { - guard let feed = representedObject as? Feed else { return false } @@ -106,21 +98,9 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) { - -// guard let feed = representedObject as? Feed else { -// return -// } -// -// print("Unexpected response \(response) for \(feed.url).") } func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) { - -// guard let feed = representedObject as? Feed else { -// return -// } -// -// print("Not modified response for \(feed.url).") } } @@ -129,7 +109,6 @@ extension LocalAccountRefresher: DownloadSessionDelegate { private extension Data { func isDefinitelyNotFeed() -> Bool { - // We only detect a few image types for now. This should get fleshed-out at some later date. return (self as NSData).rs_dataIsImage() } diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 6efba4115..fdbfb6f74 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -825,31 +825,28 @@ private extension ReaderAPIAccountDelegate { func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) { os_log(.debug, log: log, "Refreshing missing articles...") - let articleIDs = Array(account.fetchArticleIDsForStatusesWithoutArticles()) - let group = DispatchGroup() - - let chunkedArticleIDs = articleIDs.chunked(into: 100) - - for chunk in chunkedArticleIDs { - - group.enter() - caller.retrieveEntries(articleIDs: chunk) { result in - - switch result { - case .success(let entries): - - self.processEntries(account: account, entries: entries) { + + account.fetchArticleIDsForStatusesWithoutArticles { (fetchedArticleIDs) in + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + group.enter() + self.caller.retrieveEntries(articleIDs: chunk) { result in + + switch result { + case .success(let entries): + self.processEntries(account: account, entries: entries) { + group.leave() + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) group.leave() } - - case .failure(let error): - os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) - group.leave() } - } - } group.notify(queue: DispatchQueue.main) { @@ -857,7 +854,6 @@ private extension ReaderAPIAccountDelegate { os_log(.debug, log: self.log, "Done refreshing missing articles.") completion() } - } func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) { @@ -933,46 +929,34 @@ private extension ReaderAPIAccountDelegate { } func syncArticleReadState(account: Account, articleIDs: [Int]?) { - guard let articleIDs = articleIDs else { return } let unreadArticleIDs = Set(articleIDs.map { String($0) } ) - let currentUnreadArticleIDs = account.fetchUnreadArticleIDs() - - // Mark articles as unread - let deltaUnreadArticleIDs = unreadArticleIDs.subtracting(currentUnreadArticleIDs) - let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs) - DispatchQueue.main.async { - _ = account.update(markUnreadArticles, statusKey: .read, flag: false) - } - - // Save any unread statuses for articles we haven't yet received - let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) - let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) - if !missingUnreadArticleIDs.isEmpty { - DispatchQueue.main.async { + account.fetchUnreadArticleIDs { (currentUnreadArticleIDs) in + // Mark articles as unread + let deltaUnreadArticleIDs = unreadArticleIDs.subtracting(currentUnreadArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaUnreadArticleIDs)) { (markUnreadArticles) in + account.update(markUnreadArticles, statusKey: .read, flag: false) + + // Save any unread statuses for articles we haven't yet received + let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) + let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) account.ensureStatuses(missingUnreadArticleIDs, .read, false) } - } - - // Mark articles as read - let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(unreadArticleIDs) - let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) - DispatchQueue.main.async { - _ = account.update(markReadArticles, statusKey: .read, flag: true) - } - - // Save any read statuses for articles we haven't yet received - let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) - let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) - if !missingReadArticleIDs.isEmpty { - DispatchQueue.main.async { + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(unreadArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaReadArticleIDs)) { (markReadArticles) in + account.update(markReadArticles, statusKey: .read, flag: true) + + // Save any read statuses for articles we haven't yet received + let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) + let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) account.ensureStatuses(missingReadArticleIDs, .read, true) } } - } func syncArticleStarredState(account: Account, articleIDs: [Int]?) { @@ -982,40 +966,29 @@ private extension ReaderAPIAccountDelegate { } let starredArticleIDs = Set(articleIDs.map { String($0) } ) - let currentStarredArticleIDs = account.fetchStarredArticleIDs() - - // Mark articles as starred - let deltaStarredArticleIDs = starredArticleIDs.subtracting(currentStarredArticleIDs) - let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs) - DispatchQueue.main.async { - _ = account.update(markStarredArticles, statusKey: .starred, flag: true) - } - - // Save any starred statuses for articles we haven't yet received - let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) - let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) - if !missingStarredArticleIDs.isEmpty { - DispatchQueue.main.async { + account.fetchStarredArticleIDs { (currentStarredArticleIDs) in + // Mark articles as starred + let deltaStarredArticleIDs = starredArticleIDs.subtracting(currentStarredArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaStarredArticleIDs)) { (markStarredArticles) in + account.update(markStarredArticles, statusKey: .starred, flag: true) + + // Save any starred statuses for articles we haven't yet received + let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) + let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) account.ensureStatuses(missingStarredArticleIDs, .starred, true) } - } - - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(starredArticleIDs) - let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) - DispatchQueue.main.async { - _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) - } - - // Save any unstarred statuses for articles we haven't yet received - let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) - let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) - if !missingUnstarredArticleIDs.isEmpty { - DispatchQueue.main.async { + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(starredArticleIDs) + account.fetchArticlesAsync(.articleIDs(deltaUnstarredArticleIDs)) { (markUnstarredArticles) in + account.update(markUnstarredArticles, statusKey: .starred, flag: false) + + // Save any unstarred statuses for articles we haven't yet received + let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) + let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) account.ensureStatuses(missingUnstarredArticleIDs, .starred, false) } } - } func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/UnreadCountProvider.swift b/Frameworks/Account/UnreadCountProvider.swift index 282ec3c62..e776c8285 100644 --- a/Frameworks/Account/UnreadCountProvider.swift +++ b/Frameworks/Account/UnreadCountProvider.swift @@ -25,12 +25,10 @@ public protocol UnreadCountProvider { public extension UnreadCountProvider { func postUnreadCountDidChangeNotification() { - NotificationCenter.default.post(name: .UnreadCountDidChange, object: self, userInfo: nil) } func calculateUnreadCount(_ children: T) -> Int { - let updatedUnreadCount = children.reduce(0) { (result, oneChild) -> Int in if let oneUnreadCountProvider = oneChild as? UnreadCountProvider { return result + oneUnreadCountProvider.unreadCount diff --git a/Frameworks/Articles/Article.swift b/Frameworks/Articles/Article.swift index b7dce4d9a..cd40d5cb6 100644 --- a/Frameworks/Articles/Article.swift +++ b/Frameworks/Articles/Article.swift @@ -1,6 +1,6 @@ // // Article.swift -// Data +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -8,6 +8,8 @@ import Foundation +public typealias ArticleSetBlock = (Set
) -> Void + public struct Article: Hashable { public let articleID: String // Unique database ID (possibly sync service ID) diff --git a/Frameworks/Articles/ArticleStatus.swift b/Frameworks/Articles/ArticleStatus.swift index 7ed3779ec..4aaa8ee02 100644 --- a/Frameworks/Articles/ArticleStatus.swift +++ b/Frameworks/Articles/ArticleStatus.swift @@ -1,6 +1,6 @@ // // ArticleStatus.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. @@ -30,7 +30,6 @@ public final class ArticleStatus: Hashable { public var userDeleted = false public init(articleID: String, read: Bool, starred: Bool, userDeleted: Bool, dateArrived: Date) { - self.articleID = articleID self.read = read self.starred = starred @@ -39,12 +38,10 @@ public final class ArticleStatus: Hashable { } public convenience init(articleID: String, read: Bool, dateArrived: Date) { - self.init(articleID: articleID, read: read, starred: false, userDeleted: false, dateArrived: dateArrived) } public func boolStatus(forKey key: ArticleStatus.Key) -> Bool { - switch key { case .read: return read @@ -56,7 +53,6 @@ public final class ArticleStatus: Hashable { } public func setBoolStatus(_ status: Bool, forKey key: ArticleStatus.Key) { - switch key { case .read: read = status @@ -76,7 +72,6 @@ public final class ArticleStatus: Hashable { // MARK: - Equatable public static func ==(lhs: ArticleStatus, rhs: ArticleStatus) -> Bool { - return lhs.articleID == rhs.articleID && lhs.dateArrived == rhs.dateArrived && lhs.read == rhs.read && lhs.starred == rhs.starred && lhs.userDeleted == rhs.userDeleted } } @@ -84,15 +79,13 @@ public final class ArticleStatus: Hashable { public extension Set where Element == ArticleStatus { func articleIDs() -> Set { - return Set(map { $0.articleID }) } } public extension Array where Element == ArticleStatus { - func articleIDs() -> [String] { - + func articleIDs() -> [String] { return map { $0.articleID } } } diff --git a/Frameworks/Articles/Attachment.swift b/Frameworks/Articles/Attachment.swift index 7635a62c2..8fab1b317 100644 --- a/Frameworks/Articles/Attachment.swift +++ b/Frameworks/Articles/Attachment.swift @@ -1,6 +1,6 @@ // // Attachment.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. diff --git a/Frameworks/Articles/Author.swift b/Frameworks/Articles/Author.swift index 53724e229..7debc8868 100644 --- a/Frameworks/Articles/Author.swift +++ b/Frameworks/Articles/Author.swift @@ -1,6 +1,6 @@ // // Author.swift -// DataModel +// NetNewsWire // // Created by Brent Simmons on 7/1/17. // Copyright © 2017 Ranchero Software, LLC. All rights reserved. diff --git a/Frameworks/Articles/DatabaseID.swift b/Frameworks/Articles/DatabaseID.swift index 2729a04b2..392ca1870 100644 --- a/Frameworks/Articles/DatabaseID.swift +++ b/Frameworks/Articles/DatabaseID.swift @@ -1,6 +1,6 @@ // // DatabaseID.swift -// Data +// NetNewsWire // // Created by Brent Simmons on 7/15/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -17,7 +17,6 @@ private var databaseIDCache = [String: String]() private var databaseIDCacheLock = os_unfair_lock_s() public func databaseIDWithString(_ s: String) -> String { - os_unfair_lock_lock(&databaseIDCacheLock) defer { os_unfair_lock_unlock(&databaseIDCacheLock) diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index 6802974b5..19ba89f6a 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -7,26 +7,24 @@ // import Foundation -import RSCore 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
) -> Void +// Main thread only. + +public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount public typealias UnreadCountCompletionBlock = (UnreadCountDictionary) -> Void public typealias UpdateArticlesWithFeedCompletionBlock = (Set
?, Set
?) -> Void //newArticles, updatedArticles public final class ArticlesDatabase { - private let accountID: String private let articlesTable: ArticlesTable public init(databaseFilePath: String, accountID: String) { - self.accountID = accountID - let queue = RSDatabaseQueue(filepath: databaseFilePath, excludeFromBackup: false) self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue) @@ -46,38 +44,60 @@ public final class ArticlesDatabase { // MARK: - Fetching Articles - public func fetchArticles(for feedID: String) -> Set
{ + public func fetchArticles(_ feedID: String) -> Set
{ return articlesTable.fetchArticles(feedID) } - public func fetchArticles(forArticleIDs articleIDs: Set) -> Set
{ - return articlesTable.fetchArticles(forArticleIDs: articleIDs) + public func fetchArticles(articleIDs: Set) -> Set
{ + 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) -> Set
{ + return articlesTable.fetchUnreadArticles(feedIDs) } - public func fetchUnreadArticles(for feedIDs: Set) -> Set
{ - return articlesTable.fetchUnreadArticles(for: feedIDs) + public func fetchTodayArticles(_ feedIDs: Set) -> Set
{ + return articlesTable.fetchTodayArticles(feedIDs) } - public func fetchTodayArticles(for feedIDs: Set) -> Set
{ - return articlesTable.fetchTodayArticles(for: feedIDs) + public func fetchStarredArticles(_ feedIDs: Set) -> Set
{ + return articlesTable.fetchStarredArticles(feedIDs) } - public func fetchStarredArticles(for feedIDs: Set) -> Set
{ - return articlesTable.fetchStarredArticles(for: feedIDs) + public func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set) -> Set
{ + return articlesTable.fetchArticlesMatching(searchString, feedIDs) } - public func fetchArticlesMatching(_ searchString: String, for feedIDs: Set) -> Set
{ - 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, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchArticlesAsync(articleIDs: articleIDs, callback) + } + + public func fetchUnreadArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchUnreadArticlesAsync(feedIDs, callback) + } + + public func fetchTodayArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchTodayArticlesAsync(feedIDs, callback) + } + + public func fetchedStarredArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchStarredArticlesAsync(feedIDs, callback) + } + + public func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchArticlesMatchingAsync(searchString, feedIDs, callback) } // MARK: - Unread Counts - public func fetchUnreadCounts(for feedIDs: Set, _ completion: @escaping UnreadCountCompletionBlock) { - articlesTable.fetchUnreadCounts(feedIDs, completion) + public func fetchUnreadCounts(for feedIDs: Set, _ callback: @escaping UnreadCountCompletionBlock) { + articlesTable.fetchUnreadCounts(feedIDs, callback) } public func fetchUnreadCount(for feedIDs: Set, since: Date, callback: @escaping (Int) -> Void) { @@ -88,8 +108,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 @@ -104,16 +124,16 @@ public final class ArticlesDatabase { // MARK: - Status - public func fetchUnreadArticleIDs() -> Set { - return articlesTable.fetchUnreadArticleIDs() + public func fetchUnreadArticleIDs(_ callback: @escaping (Set) -> Void) { + articlesTable.fetchUnreadArticleIDs(callback) } - public func fetchStarredArticleIDs() -> Set { - return articlesTable.fetchStarredArticleIDs() + public func fetchStarredArticleIDs(_ callback: @escaping (Set) -> Void) { + articlesTable.fetchStarredArticleIDs(callback) } - public func fetchArticleIDsForStatusesWithoutArticles() -> Set { - return articlesTable.fetchArticleIDsForStatusesWithoutArticles() + public func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set) -> Void) { + articlesTable.fetchArticleIDsForStatusesWithoutArticles(callback) } public func mark(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set? { diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj index f95d5bdb9..aeb7db758 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj @@ -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 = ""; }; 8461461E1F0ABC7300870CB3 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTable.swift; sourceTree = ""; }; - 848AD2951F58A91E004FB0EC /* UnreadCountDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadCountDictionary.swift; sourceTree = ""; }; 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 */, diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 6379e3f7d..9b33a3289 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -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
+ 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: - Fetching Articles for Feed func fetchArticles(_ feedID: String) -> Set
{ - - var articles = Set
() + 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
{ + return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits) + } + + // MARK: - Fetching Articles by articleID + + func fetchArticles(articleIDs: Set) -> Set
{ + return fetchArticles{ self.fetchArticles(articleIDs: articleIDs, $0) } + } + + func fetchArticlesAsync(articleIDs: Set, _ callback: @escaping ArticleSetBlock) { + return fetchArticlesAsync({ self.fetchArticles(articleIDs: articleIDs, $0) }, callback) + } + + private func fetchArticles(articleIDs: Set, _ database: FMDatabase) -> Set
{ + if articleIDs.isEmpty { + return Set
() + } + 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: - Fetching Unread Articles + + func fetchUnreadArticles(_ feedIDs: Set) -> Set
{ + return fetchArticles{ self.fetchUnreadArticles(feedIDs, $0) } + } + + func fetchUnreadArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync({ self.fetchUnreadArticles(feedIDs, $0) }, callback) + } + + private func fetchUnreadArticles(_ feedIDs: Set, _ database: FMDatabase) -> Set
{ + // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0 + if feedIDs.isEmpty { + return Set
() + } + 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: - Fetching Today Articles + + func fetchTodayArticles(_ feedIDs: Set) -> Set
{ + return fetchArticles{ self.fetchTodayArticles(feedIDs, $0) } + } + + func fetchTodayArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync({ self.fetchTodayArticles(feedIDs, $0) }, callback) + } + + private func fetchTodayArticles(_ feedIDs: Set, _ database: FMDatabase) -> Set
{ + // 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
() + } + 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: - Fetching Starred Articles + + func fetchStarredArticles(_ feedIDs: Set) -> Set
{ + return fetchArticles{ self.fetchStarredArticles(feedIDs, $0) } + } + + func fetchStarredArticlesAsync(_ feedIDs: Set, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync({ self.fetchStarredArticles(feedIDs, $0) }, callback) + } + + private func fetchStarredArticles(_ feedIDs: Set, _ database: FMDatabase) -> Set
{ + // 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
() + } + 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: - Fetching Search Articles - public func fetchArticles(forArticleIDs articleIDs: Set) -> Set
{ - - 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) -> Set
{ - - return fetchUnreadArticles(feedIDs) - } - - public func fetchTodayArticles(for feedIDs: Set) -> Set
{ - - return fetchTodayArticles(feedIDs) - } - - public func fetchStarredArticles(for feedIDs: Set) -> Set
{ - - return fetchStarredArticles(feedIDs) - } - - func fetchArticlesMatching(_ searchString: String, for feedIDs: Set) -> Set
{ + func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set) -> Set
{ var articles: Set
= Set
() 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, _ 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
() + } + + 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: - Fetching Articles for Indexer + func fetchArticleSearchInfos(_ articleIDs: Set, in database: FMDatabase) -> Set? { let parameters = articleIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))! @@ -122,10 +207,9 @@ final class ArticlesTable: DatabaseTable { return nil } - // MARK: Updating + // MARK: - Updating func update(_ feedID: String, _ parsedItems: Set, _ read: Bool, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) { - if parsedItems.isEmpty { completion(nil, nil) return @@ -143,7 +227,6 @@ final class ArticlesTable: DatabaseTable { let articleIDs = Set(parsedItems.map { $0.articleID }) self.queue.update { (database) in - let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 assert(statusesDictionary.count == articleIDs.count) @@ -159,7 +242,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 @@ -179,26 +262,23 @@ final class ArticlesTable: DatabaseTable { if articleIDs.isEmpty { return } - DispatchQueue.main.async() { + DispatchQueue.main.async { self.searchTable.ensureIndexedArticles(for: articleIDs) } } } func ensureStatuses(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool) { - - self.queue.updateSync { (database) in + self.queue.update { (database) in let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) let statuses = Set(statusesDictionary.values) - _ = self.statusesTable.mark(statuses, statusKey, flag, database) + self.statusesTable.mark(statuses, statusKey, flag, database) } - } - // MARK: Unread Counts + // MARK: - Unread Counts func fetchUnreadCounts(_ feedIDs: Set, _ completion: @escaping UnreadCountCompletionBlock) { - if feedIDs.isEmpty { completion(UnreadCountDictionary()) return @@ -207,19 +287,17 @@ final class ArticlesTable: DatabaseTable { var unreadCountDictionary = UnreadCountDictionary() queue.fetch { (database) in - for feedID in feedIDs { unreadCountDictionary[feedID] = self.fetchUnreadCount(feedID, database) } - DispatchQueue.main.async() { + DispatchQueue.main.async { completion(unreadCountDictionary) } } } func fetchUnreadCount(_ feedIDs: Set, _ since: Date, _ callback: @escaping (Int) -> Void) { - // Get unread count for today, for instance. if feedIDs.isEmpty { @@ -228,7 +306,6 @@ final class ArticlesTable: DatabaseTable { } queue.fetch { (database) in - let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and read=0 and userDeleted=0;" @@ -239,24 +316,22 @@ final class ArticlesTable: DatabaseTable { let unreadCount = self.numberWithSQLAndParameters(sql, parameters, in: database) - DispatchQueue.main.async() { + DispatchQueue.main.async { callback(unreadCount) } } } func fetchAllUnreadCounts(_ completion: @escaping UnreadCountCompletionBlock) { - // Returns only where unreadCount > 0. let cutoffDate = articleCutoffDate queue.fetch { (database) in - let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and userDeleted=0 and (starred=1 or dateArrived>?) group by feedID;" guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) else { - DispatchQueue.main.async() { + DispatchQueue.main.async { completion(UnreadCountDictionary()) } return @@ -270,45 +345,43 @@ final class ArticlesTable: DatabaseTable { } } - DispatchQueue.main.async() { + DispatchQueue.main.async { completion(d) } } } func fetchStarredAndUnreadCount(_ feedIDs: Set, _ callback: @escaping (Int) -> Void) { - if feedIDs.isEmpty { callback(0) return } queue.fetch { (database) in - let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and starred=1 and userDeleted=0;" let parameters = Array(feedIDs) as [Any] let unreadCount = self.numberWithSQLAndParameters(sql, parameters, in: database) - DispatchQueue.main.async() { + DispatchQueue.main.async { callback(unreadCount) } } } - // MARK: Status + // MARK: - Statuses - func fetchUnreadArticleIDs() -> Set { - return statusesTable.fetchUnreadArticleIDs() + func fetchUnreadArticleIDs(_ callback: @escaping (Set) -> Void) { + statusesTable.fetchUnreadArticleIDs(callback) } - func fetchStarredArticleIDs() -> Set { - return statusesTable.fetchStarredArticleIDs() + func fetchStarredArticleIDs(_ callback: @escaping (Set) -> Void) { + statusesTable.fetchStarredArticleIDs(callback) } - func fetchArticleIDsForStatusesWithoutArticles() -> Set { - return statusesTable.fetchArticleIDsForStatusesWithoutArticles() + func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set) -> Void) { + statusesTable.fetchArticleIDsForStatusesWithoutArticles(callback) } func mark(_ articles: Set
, _ statusKey: ArticleStatus.Key, _ flag: Bool) -> Set? { @@ -319,7 +392,7 @@ final class ArticlesTable: DatabaseTable { return statuses } - // MARK: Indexing + // MARK: - Indexing func indexUnindexedArticles() { queue.fetch { (database) in @@ -344,10 +417,26 @@ final class ArticlesTable: DatabaseTable { private extension ArticlesTable { - // MARK: Fetching + // MARK: - Fetching + + private func fetchArticles(_ fetchMethod: @escaping ArticlesFetchMethod) -> Set
{ + var articles = Set
() + 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
{ - // 1. Create DatabaseArticles without related objects. // 2. Then fetch the related objects, given the set of articleIDs. // 3. Then create set of Articles with DatabaseArticles and related objects and return it. @@ -385,7 +474,6 @@ private extension ArticlesTable { } func makeDatabaseArticles(with resultSet: FMResultSet) -> Set { - let articles = resultSet.mapToSet { (row) -> DatabaseArticle? in // The resultSet is a result of a JOIN query with the statuses table, @@ -427,7 +515,6 @@ private extension ArticlesTable { } func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set
{ - // Don’t fetch articles that shouldn’t appear in the UI. The rules: // * Must not be deleted. // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. @@ -443,7 +530,6 @@ private extension ArticlesTable { } func fetchUnreadCount(_ feedID: String, _ database: FMDatabase) -> Int { - // Count only the articles that would appear in the UI. // * Must be unread. // * Must not be deleted. @@ -453,97 +539,6 @@ private extension ArticlesTable { return numberWithSQLAndParameters(sql, [feedID, articleCutoffDate], in: database) } - func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set
{ - - return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits) - } - - func fetchArticlesForIDs(_ articleIDs: Set) -> Set
{ - - if articleIDs.isEmpty { - return Set
() - } - - var articles = Set
() - - 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) -> Set
{ - - if feedIDs.isEmpty { - return Set
() - } - - var articles = Set
() - - 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) -> Set
{ - - if feedIDs.isEmpty { - return Set
() - } - - var articles = Set
() - - 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) -> Set
{ - - if feedIDs.isEmpty { - return Set
() - } - - var articles = Set
() - - 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
{ let sql = "select rowid from search where search match ?;" let sqlSearchString = sqliteSearchString(with: searchString) @@ -578,33 +573,28 @@ private extension ArticlesTable { } func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set
{ - guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { return Set
() } return articlesWithResultSet(resultSet, database) } - // MARK: Saving Parsed Items - + // MARK: - Saving Parsed Items func callUpdateArticlesCompletionBlock(_ newArticles: Set
?, _ updatedArticles: Set
?, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) { - DispatchQueue.main.async { completion(newArticles, updatedArticles) } } - // MARK: Save New Articles + // MARK: - Saving New Articles func findNewArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article]) -> Set
? { - let newArticles = Set(incomingArticles.filter { fetchedArticlesDictionary[$0.articleID] == nil }) return newArticles.isEmpty ? nil : newArticles } func findAndSaveNewArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set
? { //5 - guard let newArticles = findNewArticles(incomingArticles, fetchedArticlesDictionary) else { return nil } @@ -613,7 +603,6 @@ private extension ArticlesTable { } func saveNewArticles(_ articles: Set
, _ database: FMDatabase) { - saveRelatedObjectsForNewArticles(articles, database) if let databaseDictionaries = articles.databaseDictionaries() { @@ -622,17 +611,15 @@ private extension ArticlesTable { } func saveRelatedObjectsForNewArticles(_ articles: Set
, _ database: FMDatabase) { - let databaseObjects = articles.databaseObjects() authorsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) attachmentsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) } - // MARK: Update Existing Articles + // MARK: - Updating Existing Articles func articlesWithRelatedObjectChanges(_ comparisonKeyPath: KeyPath?>, _ updatedArticles: Set
, _ fetchedArticles: [String: Article]) -> Set
{ - return updatedArticles.filter{ (updatedArticle) -> Bool in if let fetchedArticle = fetchedArticles[updatedArticle.articleID] { return updatedArticle[keyPath: comparisonKeyPath] != fetchedArticle[keyPath: comparisonKeyPath] @@ -643,7 +630,6 @@ private extension ArticlesTable { } func updateRelatedObjects(_ comparisonKeyPath: KeyPath?>, _ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ lookupTable: DatabaseLookupTable, _ database: FMDatabase) { - let articlesWithChanges = articlesWithRelatedObjectChanges(comparisonKeyPath, updatedArticles, fetchedArticles) if !articlesWithChanges.isEmpty { lookupTable.saveRelatedObjects(for: articlesWithChanges.databaseObjects(), in: database) @@ -651,13 +637,11 @@ private extension ArticlesTable { } func saveUpdatedRelatedObjects(_ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ database: FMDatabase) { - updateRelatedObjects(\Article.authors, updatedArticles, fetchedArticles, authorsLookupTable, database) updateRelatedObjects(\Article.attachments, updatedArticles, fetchedArticles, attachmentsLookupTable, database) } func findUpdatedArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article]) -> Set
? { - let updatedArticles = incomingArticles.filter{ (incomingArticle) -> Bool in //6 if let existingArticle = fetchedArticlesDictionary[incomingArticle.articleID] { if existingArticle != incomingArticle { @@ -671,7 +655,6 @@ private extension ArticlesTable { } func findAndSaveUpdatedArticles(_ incomingArticles: Set
, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set
? { //6 - guard let updatedArticles = findUpdatedArticles(incomingArticles, fetchedArticlesDictionary) else { return nil } @@ -681,7 +664,6 @@ private extension ArticlesTable { func saveUpdatedArticles(_ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ database: FMDatabase) { - saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database) for updatedArticle in updatedArticles { @@ -690,7 +672,6 @@ private extension ArticlesTable { } func saveUpdatedArticle(_ updatedArticle: Article, _ fetchedArticles: [String: Article], _ database: FMDatabase) { - // Only update exactly what has changed in the Article (if anything). // Untested theory: this gets us better performance and less database fragmentation. @@ -699,7 +680,6 @@ private extension ArticlesTable { saveNewArticles(Set([updatedArticle]), database) return } - guard let changesDictionary = updatedArticle.changesFrom(fetchedArticle), changesDictionary.count > 0 else { // Not unexpected. There may be no changes. return @@ -709,9 +689,7 @@ private extension ArticlesTable { } func statusIndicatesArticleIsIgnorable(_ status: ArticleStatus) -> Bool { - // Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months). - if status.userDeleted { return true } @@ -722,9 +700,7 @@ private extension ArticlesTable { } func filterIncomingArticles(_ articles: Set
) -> Set
{ - // Drop Articles that we can ignore. - return Set(articles.filter{ !statusIndicatesArticleIsIgnorable($0.status) }) } } diff --git a/Frameworks/ArticlesDatabase/AttachmentsTable.swift b/Frameworks/ArticlesDatabase/AttachmentsTable.swift index f243c9a76..0a6067503 100644 --- a/Frameworks/ArticlesDatabase/AttachmentsTable.swift +++ b/Frameworks/ArticlesDatabase/AttachmentsTable.swift @@ -1,6 +1,6 @@ // // AttachmentsTable.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/15/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -17,14 +17,12 @@ final class AttachmentsTable: DatabaseRelatedObjectsTable { var cache = DatabaseObjectCache() init(name: String) { - self.name = name } - // MARK: DatabaseRelatedObjectsTable + // MARK: - DatabaseRelatedObjectsTable func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { - if let attachment = Attachment(row: row) { return attachment as DatabaseObject } diff --git a/Frameworks/ArticlesDatabase/AuthorsTable.swift b/Frameworks/ArticlesDatabase/AuthorsTable.swift index 8d041f927..543e83af4 100644 --- a/Frameworks/ArticlesDatabase/AuthorsTable.swift +++ b/Frameworks/ArticlesDatabase/AuthorsTable.swift @@ -1,6 +1,6 @@ // // AuthorsTable.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/13/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -24,14 +24,12 @@ final class AuthorsTable: DatabaseRelatedObjectsTable { var cache = DatabaseObjectCache() init(name: String) { - self.name = name } - // MARK: DatabaseRelatedObjectsTable + // MARK: - DatabaseRelatedObjectsTable func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { - if let author = Author(row: row) { return author as DatabaseObject } diff --git a/Frameworks/ArticlesDatabase/Constants.swift b/Frameworks/ArticlesDatabase/Constants.swift index 054f96919..e7b13b08e 100644 --- a/Frameworks/ArticlesDatabase/Constants.swift +++ b/Frameworks/ArticlesDatabase/Constants.swift @@ -1,6 +1,6 @@ // // Keys.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/3/17. // Copyright © 2017 Ranchero Software. All rights reserved. diff --git a/Frameworks/ArticlesDatabase/DatabaseArticle.swift b/Frameworks/ArticlesDatabase/DatabaseArticle.swift index 597decbf9..7ac09c90c 100644 --- a/Frameworks/ArticlesDatabase/DatabaseArticle.swift +++ b/Frameworks/ArticlesDatabase/DatabaseArticle.swift @@ -1,6 +1,6 @@ // // DatabaseArticle.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 9/21/17. // Copyright © 2017 Ranchero Software. All rights reserved. diff --git a/Frameworks/ArticlesDatabase/DatabaseObject+Database.swift b/Frameworks/ArticlesDatabase/DatabaseObject+Database.swift index 0d9c3d8e1..edc02e7de 100644 --- a/Frameworks/ArticlesDatabase/DatabaseObject+Database.swift +++ b/Frameworks/ArticlesDatabase/DatabaseObject+Database.swift @@ -1,6 +1,6 @@ // // DatabaseObject+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 9/13/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -13,13 +13,11 @@ import Articles extension Array where Element == DatabaseObject { func asAuthors() -> Set? { - let authors = Set(self.map { $0 as! Author }) return authors.isEmpty ? nil : authors } func asAttachments() -> Set? { - let attachments = Set(self.map { $0 as! Attachment }) return attachments.isEmpty ? nil : attachments } diff --git a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift index 5ac4dbb09..36c5332f1 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift @@ -1,6 +1,6 @@ // // Article+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/3/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -14,12 +14,10 @@ import RSParser extension Article { init(databaseArticle: DatabaseArticle, accountID: String, authors: Set?, attachments: Set?) { - self.init(accountID: accountID, articleID: databaseArticle.articleID, feedID: databaseArticle.feedID, uniqueID: databaseArticle.uniqueID, title: databaseArticle.title, contentHTML: databaseArticle.contentHTML, contentText: databaseArticle.contentText, url: databaseArticle.url, externalURL: databaseArticle.externalURL, summary: databaseArticle.summary, imageURL: databaseArticle.imageURL, bannerImageURL: databaseArticle.bannerImageURL, datePublished: databaseArticle.datePublished, dateModified: databaseArticle.dateModified, authors: authors, attachments: attachments, status: databaseArticle.status) } init(parsedItem: ParsedItem, maximumDateAllowed: Date, accountID: String, feedID: String, status: ArticleStatus) { - let authors = Author.authorsWithParsedAuthors(parsedItem.authors) let attachments = Attachment.attachmentsWithParsedAttachments(parsedItem.attachments) @@ -135,7 +133,6 @@ extension Article: DatabaseObject { } public func relatedObjectsWithName(_ name: String) -> [DatabaseObject]? { - switch name { case RelationshipName.authors: return databaseObjectArray(with: authors) @@ -147,7 +144,6 @@ extension Article: DatabaseObject { } private func databaseObjectArray(with objects: Set?) -> [DatabaseObject]? { - guard let objects = objects else { return nil } @@ -158,12 +154,10 @@ extension Article: DatabaseObject { extension Set where Element == Article { func statuses() -> Set { - return Set(map { $0.status }) } func dictionary() -> [String: Article] { - var d = [String: Article]() for article in self { d[article.articleID] = article @@ -172,12 +166,10 @@ extension Set where Element == Article { } func databaseObjects() -> [DatabaseObject] { - return self.map{ $0 as DatabaseObject } } func databaseDictionaries() -> [DatabaseDictionary]? { - return self.compactMap { $0.databaseDictionary() } } } diff --git a/Frameworks/ArticlesDatabase/Extensions/ArticleStatus+Database.swift b/Frameworks/ArticlesDatabase/Extensions/ArticleStatus+Database.swift index fbf6f4e1a..b4d46650e 100644 --- a/Frameworks/ArticlesDatabase/Extensions/ArticleStatus+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/ArticleStatus+Database.swift @@ -1,6 +1,6 @@ // // ArticleStatus+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/3/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -13,7 +13,6 @@ import Articles extension ArticleStatus { convenience init(articleID: String, dateArrived: Date, row: FMResultSet) { - let read = row.bool(forColumn: DatabaseKey.read) let starred = row.bool(forColumn: DatabaseKey.starred) let userDeleted = row.bool(forColumn: DatabaseKey.userDeleted) diff --git a/Frameworks/ArticlesDatabase/Extensions/Attachment+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Attachment+Database.swift index 15cea3bb4..dc30e5bee 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Attachment+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Attachment+Database.swift @@ -1,6 +1,6 @@ // // Attachment+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/4/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -14,7 +14,6 @@ import RSParser extension Attachment { init?(row: FMResultSet) { - guard let url = row.string(forColumn: DatabaseKey.url) else { return nil } @@ -22,19 +21,17 @@ extension Attachment { let attachmentID = row.string(forColumn: DatabaseKey.attachmentID) let mimeType = row.string(forColumn: DatabaseKey.mimeType) let title = row.string(forColumn: DatabaseKey.title) - let sizeInBytes = optionalIntForColumn(row, DatabaseKey.sizeInBytes) - let durationInSeconds = optionalIntForColumn(row, DatabaseKey.durationInSeconds) + let sizeInBytes = row.optionalIntForColumn(DatabaseKey.sizeInBytes) + let durationInSeconds = row.optionalIntForColumn(DatabaseKey.durationInSeconds) self.init(attachmentID: attachmentID, url: url, mimeType: mimeType, title: title, sizeInBytes: sizeInBytes, durationInSeconds: durationInSeconds) } init?(parsedAttachment: ParsedAttachment) { - self.init(attachmentID: nil, url: parsedAttachment.url, mimeType: parsedAttachment.mimeType, title: parsedAttachment.title, sizeInBytes: parsedAttachment.sizeInBytes, durationInSeconds: parsedAttachment.durationInSeconds) } static func attachmentsWithParsedAttachments(_ parsedAttachments: Set?) -> Set? { - guard let parsedAttachments = parsedAttachments else { return nil } @@ -42,17 +39,8 @@ extension Attachment { let attachments = parsedAttachments.compactMap{ Attachment(parsedAttachment: $0) } return attachments.isEmpty ? nil : Set(attachments) } - } -private func optionalIntForColumn(_ row: FMResultSet, _ columnName: String) -> Int? { - - let intValue = row.long(forColumn: columnName) - if intValue < 1 { - return nil - } - return intValue -} extension Attachment: DatabaseObject { @@ -79,15 +67,24 @@ extension Attachment: DatabaseObject { } +private extension FMResultSet { + + func optionalIntForColumn(_ columnName: String) -> Int? { + let intValue = long(forColumn: columnName) + if intValue < 1 { + return nil + } + return intValue + } +} + extension Set where Element == Attachment { func databaseDictionaries() -> [DatabaseDictionary] { - return self.compactMap { $0.databaseDictionary() } } func databaseObjects() -> [DatabaseObject] { - return self.compactMap { $0 as DatabaseObject } } } diff --git a/Frameworks/ArticlesDatabase/Extensions/Author+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Author+Database.swift index 1b2d7fb43..d1d69b715 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Author+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Author+Database.swift @@ -1,6 +1,6 @@ // // Author+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 7/8/17. // Copyright © 2017 Ranchero Software. All rights reserved. @@ -16,7 +16,6 @@ import RSParser extension Author { init?(row: FMResultSet) { - let authorID = row.string(forColumn: DatabaseKey.authorID) let name = row.string(forColumn: DatabaseKey.name) let url = row.string(forColumn: DatabaseKey.url) @@ -27,12 +26,10 @@ extension Author { } init?(parsedAuthor: ParsedAuthor) { - self.init(authorID: nil, name: parsedAuthor.name, url: parsedAuthor.url, avatarURL: parsedAuthor.avatarURL, emailAddress: parsedAuthor.emailAddress) } public static func authorsWithParsedAuthors(_ parsedAuthors: Set?) -> Set? { - guard let parsedAuthors = parsedAuthors else { return nil } @@ -49,7 +46,6 @@ extension Author: DatabaseObject { } public func databaseDictionary() -> DatabaseDictionary? { - var d: DatabaseDictionary = [DatabaseKey.authorID: authorID] if let name = name { d[DatabaseKey.name] = name diff --git a/Frameworks/ArticlesDatabase/Extensions/ParsedArticle+Database.swift b/Frameworks/ArticlesDatabase/Extensions/ParsedArticle+Database.swift index d89f707ab..271876057 100644 --- a/Frameworks/ArticlesDatabase/Extensions/ParsedArticle+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/ParsedArticle+Database.swift @@ -1,6 +1,6 @@ // // ParsedArticle+Database.swift -// Database +// NetNewsWire // // Created by Brent Simmons on 9/18/17. // Copyright © 2017 Ranchero Software. All rights reserved. diff --git a/Frameworks/ArticlesDatabase/RelatedObjectsMap+Database.swift b/Frameworks/ArticlesDatabase/RelatedObjectsMap+Database.swift index 09a78b807..80c1f0692 100644 --- a/Frameworks/ArticlesDatabase/RelatedObjectsMap+Database.swift +++ b/Frameworks/ArticlesDatabase/RelatedObjectsMap+Database.swift @@ -13,7 +13,6 @@ import Articles extension RelatedObjectsMap { func attachments(for articleID: String) -> Set? { - if let objects = self[articleID] { return objects.asAttachments() } @@ -21,7 +20,6 @@ extension RelatedObjectsMap { } func authors(for articleID: String) -> Set? { - if let objects = self[articleID] { return objects.asAuthors() } diff --git a/Frameworks/ArticlesDatabase/SearchTable.swift b/Frameworks/ArticlesDatabase/SearchTable.swift index 608b01cab..d0c95838a 100644 --- a/Frameworks/ArticlesDatabase/SearchTable.swift +++ b/Frameworks/ArticlesDatabase/SearchTable.swift @@ -1,6 +1,6 @@ // // SearchTable.swift -// ArticlesDatabase +// NetNewsWire // // Created by Brent Simmons on 2/23/19. // Copyright © 2019 Ranchero Software. All rights reserved. @@ -109,10 +109,6 @@ private extension SearchTable { func insert(_ article: ArticleSearchInfo, _ database: FMDatabase) -> Int { let rowDictionary: DatabaseDictionary = [DatabaseKey.body: article.bodyForIndex, DatabaseKey.title: article.title ?? ""] -// rowDictionary[DatabaseKey.title] = article.title ?? "" -// rowDictionary[DatabaseKey.body] = article.bodyForIndex -// rowDictionary.setObject(article.title ?? "", forKey: DatabaseKey.title as NSString) -// rowDictionary.setObject(article.bodyForIndex, forKey: DatabaseKey.body as NSString) insertRow(rowDictionary, insertType: .normal, in: database) return Int(database.lastInsertRowId()) } diff --git a/Frameworks/ArticlesDatabase/StatusesTable.swift b/Frameworks/ArticlesDatabase/StatusesTable.swift index 55899cfb5..e8b5078c3 100644 --- a/Frameworks/ArticlesDatabase/StatusesTable.swift +++ b/Frameworks/ArticlesDatabase/StatusesTable.swift @@ -22,14 +22,12 @@ final class StatusesTable: DatabaseTable { private let queue: RSDatabaseQueue init(queue: RSDatabaseQueue) { - self.queue = queue } - // MARK: Creating/Updating + // MARK: - Creating/Updating func ensureStatusesForArticleIDs(_ articleIDs: Set, _ read: Bool, _ database: FMDatabase) -> [String: ArticleStatus] { - // Check cache. let articleIDsMissingCachedStatus = articleIDsWithNoCachedStatus(articleIDs) if articleIDsMissingCachedStatus.isEmpty { @@ -48,16 +46,15 @@ final class StatusesTable: DatabaseTable { return statusesDictionary(articleIDs) } - // MARK: Marking + // MARK: - Marking + @discardableResult func mark(_ statuses: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set? { - // Sets flag in both memory and in database. var updatedStatuses = Set() for status in statuses { - if status.boolStatus(forKey: statusKey) == flag { continue } @@ -75,33 +72,29 @@ final class StatusesTable: DatabaseTable { return updatedStatuses } - // MARK: Fetching + // MARK: - Fetching - func fetchUnreadArticleIDs() -> Set { - return fetchArticleIDs("select articleID from statuses where read=0 and userDeleted=0;") + func fetchUnreadArticleIDs(_ callback: @escaping (Set) -> Void) { + fetchArticleIDs("select articleID from statuses where read=0 and userDeleted=0;", callback) } - func fetchStarredArticleIDs() -> Set { - return fetchArticleIDs("select articleID from statuses where starred=1 and userDeleted=0;") + func fetchStarredArticleIDs(_ callback: @escaping (Set) -> Void) { + fetchArticleIDs("select articleID from statuses where starred=1 and userDeleted=0;", callback) } - func fetchArticleIDsForStatusesWithoutArticles() -> Set { - return fetchArticleIDs("select articleID from statuses s where (read=0 or starred=1) and userDeleted=0 and not exists (select 1 from articles a where a.articleID = s.articleID);") + func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set) -> Void) { + fetchArticleIDs("select articleID from statuses s where (read=0 or starred=1) and userDeleted=0 and not exists (select 1 from articles a where a.articleID = s.articleID);", callback) } - func fetchArticleIDs(_ sql: String) -> Set { - - var statuses: Set? = nil - - queue.fetchSync { (database) in - if let resultSet = database.executeQuery(sql, withArgumentsIn: nil) { - statuses = resultSet.mapToSet(self.articleIDWithRow) + func fetchArticleIDs(_ sql: String, _ callback: @escaping (Set) -> Void) { + queue.fetch { (database) in + guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else { + callback(Set()) + return } - + let statuses = resultSet.mapToSet(self.articleIDWithRow) + callback(statuses) } - - return statuses != nil ? statuses! : Set() - } func articleIDWithRow(_ row: FMResultSet) -> String? { @@ -109,7 +102,6 @@ final class StatusesTable: DatabaseTable { } func statusWithRow(_ row: FMResultSet) -> ArticleStatus? { - guard let articleID = row.string(forColumn: DatabaseKey.articleID) else { return nil } @@ -128,7 +120,6 @@ final class StatusesTable: DatabaseTable { } func statusesDictionary(_ articleIDs: Set) -> [String: ArticleStatus] { - var d = [String: ArticleStatus]() for articleID in articleIDs { @@ -145,23 +136,20 @@ final class StatusesTable: DatabaseTable { private extension StatusesTable { - // MARK: Cache + // MARK: - Cache func articleIDsWithNoCachedStatus(_ articleIDs: Set) -> Set { - return Set(articleIDs.filter { cache[$0] == nil }) } - // MARK: Creating + // MARK: - Creating func saveStatuses(_ statuses: Set, _ database: FMDatabase) { - let statusArray = statuses.map { $0.databaseDictionary()! } self.insertRows(statusArray, insertType: .orIgnore, in: database) } func createAndSaveStatusesForArticleIDs(_ articleIDs: Set, _ read: Bool, _ database: FMDatabase) { - let now = Date() let statuses = Set(articleIDs.map { ArticleStatus(articleID: $0, read: read, dateArrived: now) }) cache.addIfNotCached(statuses) @@ -170,7 +158,6 @@ private extension StatusesTable { } func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set, _ database: FMDatabase) { - guard let resultSet = self.selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) else { return } @@ -179,10 +166,9 @@ private extension StatusesTable { self.cache.addIfNotCached(statuses) } - // MARK: Marking + // MARK: - Marking func markArticleIDs(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) { - updateRowsWithValue(NSNumber(value: flag), valueKey: statusKey.rawValue, whereKey: DatabaseKey.articleID, matches: Array(articleIDs), database: database) } } @@ -199,21 +185,17 @@ private final class StatusCache { } func add(_ statuses: Set) { - // Replaces any cached statuses. - for status in statuses { self[status.articleID] = status } } func addStatusIfNotCached(_ status: ArticleStatus) { - addIfNotCached(Set([status])) } func addIfNotCached(_ statuses: Set) { - // Does not replace already cached statuses. for status in statuses { diff --git a/Frameworks/ArticlesDatabase/UnreadCountDictionary.swift b/Frameworks/ArticlesDatabase/UnreadCountDictionary.swift deleted file mode 100644 index 02a9e432e..000000000 --- a/Frameworks/ArticlesDatabase/UnreadCountDictionary.swift +++ /dev/null @@ -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 - } - } -} diff --git a/Mac/MainWindow/Timeline/FetchRequestOperation.swift b/Mac/MainWindow/Timeline/FetchRequestOperation.swift new file mode 100644 index 000000000..e171f535f --- /dev/null +++ b/Mac/MainWindow/Timeline/FetchRequestOperation.swift @@ -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
, 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
(), self) + completion(self) + return + } + + let numberOfFetchers = articleFetchers.count + var fetchersReturned = 0 + var fetchedArticles = Set
() + 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) + } + } + } + } +} + diff --git a/Mac/MainWindow/Timeline/FetchRequestQueue.swift b/Mac/MainWindow/Timeline/FetchRequestQueue.swift new file mode 100644 index 000000000..d68e21c98 --- /dev/null +++ b/Mac/MainWindow/Timeline/FetchRequestQueue.swift @@ -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 } + } +} diff --git a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift index 807829845..12f6d62d2 100644 --- a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift @@ -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() { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 60f76fa0f..092a12906 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -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) + } } } } @@ -778,13 +787,17 @@ extension TimelineViewController: NSTableViewDelegate { } private func featuredImageFor(_ article: Article) -> NSImage? { - - if let url = article.imageURL { - if let imageData = appDelegate.imageDownloader.image(for: url) { - return NSImage(data: imageData) - } - } + // At this writing (17 June 2019) we’re not displaying featured images anywhere, + // so let’s skip downloading them even if we find them. + // + // We’ll revisit this later. +// if let url = article.imageURL { +// if let imageData = appDelegate.imageDownloader.image(for: url) { +// return NSImage(data: imageData) +// } +// } + return nil } @@ -838,7 +851,6 @@ private extension TimelineViewController { } func emptyTheTimeline() { - if !articles.isEmpty { articles = [Article]() } @@ -848,7 +860,7 @@ private extension TimelineViewController { performBlockAndRestoreSelection { let unsortedArticles = Set(articles) - updateArticles(with: unsortedArticles) + replaceArticles(with: unsortedArticles) } } @@ -915,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 there’s no async delay, + // so that the entire display refreshes at once. + // It’s 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
) { + 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
) { let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection) if articles != sortedArticles { @@ -934,20 +967,35 @@ private extension TimelineViewController { } } - func fetchUnsortedArticles(for representedObjects: [Any]) -> Set
{ - - var fetchedArticles = Set
() - - for object in representedObjects { - - if let articleFetcher = object as? ArticleFetcher { - fetchedArticles.formUnion(articleFetcher.fetchArticles()) - } + func fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set
{ + cancelPendingAsyncFetches() + let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher } + if articleFetchers.isEmpty { + return Set
() } + var fetchedArticles = Set
() + 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 it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t 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)) diff --git a/Mac/Resources/Credits.rtf b/Mac/Resources/Credits.rtf index 9f75bfd28..164db859e 100644 --- a/Mac/Resources/Credits.rtf +++ b/Mac/Resources/Credits.rtf @@ -1,4 +1,4 @@ -{\rtf1\ansi\ansicpg1252\cocoartf1671\cocoasubrtf200 +{\rtf1\ansi\ansicpg1252\cocoartf1671\cocoasubrtf500 {\fonttbl\f0\fnil\fcharset0 LucidaGrande-Bold;\f1\fnil\fcharset0 LucidaGrande;} {\colortbl;\red255\green255\blue255;\red0\green0\blue0;} {\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;} @@ -32,7 +32,7 @@ Major code contributors: {\field{\*\fldinst{HYPERLINK "https://github.com/olofhe \f0\b \cf2 Thanks:\ \pard\pardeftab720\li360\sa60\partightenfactor0 -\f1\b0 \cf2 Thanks to Sheila and my family; thanks to my friends in Seattle and around the globe; thanks to my co-workers and friends at {\field{\*\fldinst{HYPERLINK "https://www.omnigroup.com/"}}{\fldrslt The Omni Group}}; thanks to the ever-patient and ever-awesome NetNewsWire beta testers.\ +\f1\b0 \cf2 Thanks to Sheila and my family; thanks to my friends in Seattle and around the globe; thanks to my co-workers and friends at {\field{\*\fldinst{HYPERLINK "https://www.omnigroup.com/"}}{\fldrslt The Omni Group}}; thanks to the ever-patient and ever-awesome NetNewsWire beta testers. Thanks to {\field{\*\fldinst{HYPERLINK "https://github.com/"}}{\fldrslt GitHub}}, {\field{\*\fldinst{HYPERLINK "https://slack.com/"}}{\fldrslt Slack}}, and {\field{\*\fldinst{HYPERLINK "https://circleci.com/"}}{\fldrslt CircleCI}} for making open source collaboration easy and fun.\ \ \pard\pardeftab720\sa60\partightenfactor0 diff --git a/Mac/Resources/NetNewsWire.sdef b/Mac/Resources/NetNewsWire.sdef index c025b6e1d..eecc40413 100644 --- a/Mac/Resources/NetNewsWire.sdef +++ b/Mac/Resources/NetNewsWire.sdef @@ -134,6 +134,7 @@ + diff --git a/Mac/Scriptability/Author+Scriptability.swift b/Mac/Scriptability/Author+Scriptability.swift index d4156144e..1b39cb0e9 100644 --- a/Mac/Scriptability/Author+Scriptability.swift +++ b/Mac/Scriptability/Author+Scriptability.swift @@ -27,6 +27,11 @@ class ScriptableAuthor: NSObject, UniqueIdScriptingObject { return (scriptObjectSpecifier) } + @objc(scriptingSpecifierDescriptor) + func scriptingSpecifierDescriptor() -> NSScriptObjectSpecifier { + return (self.objectSpecifier ?? NSScriptObjectSpecifier() ) + } + // MARK: --- ScriptingObject protocol --- var scriptingKey: String { @@ -35,9 +40,6 @@ class ScriptableAuthor: NSObject, UniqueIdScriptingObject { // MARK: --- UniqueIdScriptingObject protocol --- - // I am not sure if account should prefer to be specified by name or by ID - // but in either case it seems like the accountID would be used as the keydata, so I chose ID - @objc(uniqueId) var scriptingUniqueId:Any { return author.authorID diff --git a/Mac/Scriptability/Feed+Scriptability.swift b/Mac/Scriptability/Feed+Scriptability.swift index 42fefde87..40e6dfa0b 100644 --- a/Mac/Scriptability/Feed+Scriptability.swift +++ b/Mac/Scriptability/Feed+Scriptability.swift @@ -12,7 +12,7 @@ import Account import Articles @objc(ScriptableFeed) -class ScriptableFeed: NSObject, UniqueIdScriptingObject, ScriptingObjectContainer{ +class ScriptableFeed: NSObject, UniqueIdScriptingObject, ScriptingObjectContainer { let feed:Feed let container:ScriptingObjectContainer diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 3285bfd98..bb67f67ab 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -270,8 +270,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 */; }; @@ -880,10 +886,13 @@ 84C9FC9C2262A1A900D921D6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84C9FCA02262A1B300D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 84C9FCA32262A1B800D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestQueue.swift; sourceTree = ""; }; + 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestOperation.swift; sourceTree = ""; }; 84CBDDAE1FD3674C005A61AA /* Technotes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Technotes; sourceTree = ""; }; 84CC88171FE59CBF00644329 /* SmartFeedsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedsController.swift; sourceTree = ""; }; 84D2200922B0BC4B0019E085 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; 84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStatusBarView.swift; sourceTree = ""; }; + 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedDelegate.swift; sourceTree = ""; }; 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = ""; }; 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = ""; }; 84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = ""; }; @@ -1426,6 +1435,8 @@ 84E8E0DA202EC49300562D8F /* TimelineViewController+ContextualMenus.swift */, 849A97691ED9EBC8007D329B /* TimelineTableRowView.swift */, 849A976A1ED9EBC8007D329B /* TimelineTableView.swift */, + 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */, + 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */, 844B5B6C1FEA282400C7C76A /* Keyboard */, 84E95D231FB1087500552D99 /* ArticlePasteboardWriter.swift */, 849A976F1ED9EC04007D329B /* Cell */, @@ -1766,6 +1777,7 @@ 84F2D5351FC22FCB00998D64 /* PseudoFeed.swift */, 84F2D5391FC2308B00998D64 /* UnreadFeed.swift */, 845EE7C01FC2488C00854A1F /* SmartFeed.swift */, + 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */, 84F2D5361FC22FCB00998D64 /* TodayFeedDelegate.swift */, 845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */, 8477ACBD22238E9500DF7F37 /* SearchFeedDelegate.swift */, @@ -1966,12 +1978,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 = { @@ -1981,7 +1993,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Manual; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -1991,7 +2003,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 9C84TZ7Q6Z; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -2347,6 +2359,8 @@ 51C452852265093600C03939 /* AddFeedFolderPickerData.swift in Sources */, 51C4526B226508F600C03939 /* MasterFeedViewController.swift in Sources */, 5126EE97226CB48A00C22AFC /* AppCoordinator.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 */, @@ -2380,6 +2394,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 */, @@ -2395,6 +2410,7 @@ 51C452882265093600C03939 /* AddFeedViewController.swift in Sources */, DF999FF722B5AEFA0064B687 /* SafariView.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 */, @@ -2490,6 +2506,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 */, @@ -2500,6 +2517,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 */, @@ -2521,6 +2539,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 */, diff --git a/Shared/Favicons/ColorHash.swift b/Shared/Favicons/ColorHash.swift index 8849a0fe3..650a70b76 100644 --- a/Shared/Favicons/ColorHash.swift +++ b/Shared/Favicons/ColorHash.swift @@ -13,7 +13,7 @@ import UIKit #elseif os(watchOS) import WatchKit #elseif os(OSX) -import Cocoa +import AppKit #endif public class ColorHash { diff --git a/Shared/SmartFeeds/SearchFeedDelegate.swift b/Shared/SmartFeeds/SearchFeedDelegate.swift index b68c8bf21..a9ca5de83 100644 --- a/Shared/SmartFeeds/SearchFeedDelegate.swift +++ b/Shared/SmartFeeds/SearchFeedDelegate.swift @@ -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
{ - var articles = Set
() - for account in AccountManager.shared.activeAccounts { - articles.formUnion(account.fetchArticlesMatching(searchString)) - } - return articles - } - - func fetchUnreadArticles() -> Set
{ - return fetchArticles().unreadArticles() - } -} diff --git a/Shared/SmartFeeds/SmartFeed.swift b/Shared/SmartFeeds/SmartFeed.swift index 22f629d69..436198d79 100644 --- a/Shared/SmartFeeds/SmartFeed.swift +++ b/Shared/SmartFeeds/SmartFeed.swift @@ -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
{ return delegate.fetchUnreadArticles() } + + func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { + delegate.fetchUnreadArticlesAsync(callback) + } } private extension SmartFeed { diff --git a/Shared/SmartFeeds/SmartFeedDelegate.swift b/Shared/SmartFeeds/SmartFeedDelegate.swift new file mode 100644 index 000000000..65da30094 --- /dev/null +++ b/Shared/SmartFeeds/SmartFeedDelegate.swift @@ -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
{ + return AccountManager.shared.fetchArticles(fetchType) + } + + func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) { + AccountManager.shared.fetchArticlesAsync(fetchType, callback) + } + + func fetchUnreadArticles() -> Set
{ + return fetchArticles().unreadArticles() + } + + func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync{ callback($0.unreadArticles()) } + } +} diff --git a/Shared/SmartFeeds/StarredFeedDelegate.swift b/Shared/SmartFeeds/StarredFeedDelegate.swift index 8f1553014..f71689553 100644 --- a/Shared/SmartFeeds/StarredFeedDelegate.swift +++ b/Shared/SmartFeeds/StarredFeedDelegate.swift @@ -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
{ - - var articles = Set
() - for account in AccountManager.shared.activeAccounts { - articles.formUnion(account.fetchStarredArticles()) - } - return articles - } - - func fetchUnreadArticles() -> Set
{ - - return fetchArticles().unreadArticles() - } - } diff --git a/Shared/SmartFeeds/TodayFeedDelegate.swift b/Shared/SmartFeeds/TodayFeedDelegate.swift index a06d7e623..9d717c70f 100644 --- a/Shared/SmartFeeds/TodayFeedDelegate.swift +++ b/Shared/SmartFeeds/TodayFeedDelegate.swift @@ -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
{ - - var articles = Set
() - for account in AccountManager.shared.activeAccounts { - articles.formUnion(account.fetchTodayArticles()) - } - return articles - } - - func fetchUnreadArticles() -> Set
{ - - return fetchArticles().unreadArticles() - } } diff --git a/Shared/SmartFeeds/UnreadFeed.swift b/Shared/SmartFeeds/UnreadFeed.swift index 855311aa2..80057c775 100644 --- a/Shared/SmartFeeds/UnreadFeed.swift +++ b/Shared/SmartFeeds/UnreadFeed.swift @@ -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
{ - return fetchUnreadArticles() } - func fetchUnreadArticles() -> Set
{ + func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) { + fetchUnreadArticlesAsync(callback) + } - var articles = Set
() - for account in AccountManager.shared.activeAccounts { - articles.formUnion(account.fetchUnreadArticles()) - } - return articles + func fetchUnreadArticles() -> Set
{ + return AccountManager.shared.fetchArticles(fetchType) + } + + func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { + AccountManager.shared.fetchArticlesAsync(fetchType, callback) } } diff --git a/Technotes/SparkleUpdateError.markdown b/Technotes/SparkleUpdateError.markdown new file mode 100644 index 000000000..50f81a32f --- /dev/null +++ b/Technotes/SparkleUpdateError.markdown @@ -0,0 +1,21 @@ +# “Can’t Update” Error + +If NetNewsWire’s auto-updater gives you an error that it can’t be updated, do this: + +* Cancel the update if you still need to +* Quit NetNewsWire +* Move NetNewsWire to your Applications folder (or to your `~/Applications/` folder) +* Launch NetNewsWire +* Check for Updates again + +That should do the trick! + +## The problem + +If you’re running the app from your `~/Downloads` folder, then the system has placed your app under quarantine — which means the app can’t update itself. + +Once you move the app to another folder, the quarantine is lifted, and the app can update itself. + +This *does* require your manual intervention: it‘s not something NetNewsWire can do for you automatically. + +For more info, [see this bug](https://github.com/brentsimmons/NetNewsWire/issues/213). diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 7021df30b..fa2bbb429 100644 --- a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -3,109 +3,109 @@ { "size" : "20x20", "idiom" : "iphone", - "filename" : "Icon-20@2x.png", + "filename" : "icon-41.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", - "filename" : "Icon-20@3x.png", + "filename" : "icon-60.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", - "filename" : "Icon-29@2x.png", + "filename" : "icon-58.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", - "filename" : "Icon-29@3x.png", + "filename" : "icon-87.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", - "filename" : "Icon-40@2x.png", + "filename" : "icon-80.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", - "filename" : "Icon-40@3x.png", + "filename" : "icon-121.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", - "filename" : "Icon-60@2x.png", + "filename" : "icon-120.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", - "filename" : "Icon-60@3x.png", + "filename" : "icon-180.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", - "filename" : "Icon-20.png", + "filename" : "icon-20.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", - "filename" : "Icon-20@2x-1.png", + "filename" : "icon-42.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", - "filename" : "Icon-29.png", + "filename" : "icon-29.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", - "filename" : "Icon-29@2x-1.png", + "filename" : "icon-59.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", - "filename" : "Icon-40.png", + "filename" : "icon-40.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", - "filename" : "Icon-40@2x-1.png", + "filename" : "icon-81.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", - "filename" : "Icon-76.png", + "filename" : "icon-76.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", - "filename" : "Icon-76@2x.png", + "filename" : "icon-152.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", - "filename" : "Icon-83.5@2x.png", + "filename" : "icon-167.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", - "filename" : "Icon-1024.png", + "filename" : "icon-1024.png", "scale" : "1x" } ], diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png deleted file mode 100644 index 1cd7fa503..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png deleted file mode 100644 index 7b1d6f237..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png deleted file mode 100644 index 6d03cae40..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png deleted file mode 100644 index 6d03cae40..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png deleted file mode 100644 index 1f810aaf9..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png deleted file mode 100644 index 11ab0ccef..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png deleted file mode 100644 index c8a8c77c1..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png deleted file mode 100644 index c8a8c77c1..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png deleted file mode 100644 index 7af496a15..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png deleted file mode 100644 index 6d03cae40..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png deleted file mode 100644 index c48bcc837..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png deleted file mode 100644 index c48bcc837..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png deleted file mode 100644 index 2824aca31..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png deleted file mode 100644 index 2824aca31..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png deleted file mode 100644 index c985aa549..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png deleted file mode 100644 index cca369b85..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png deleted file mode 100644 index 48d6bb9d2..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png deleted file mode 100644 index 53ce7d05c..000000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 000000000..70538dd0e Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png new file mode 100644 index 000000000..adc262902 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-121.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-121.png new file mode 100644 index 000000000..adc262902 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-121.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png new file mode 100644 index 000000000..55e255eb4 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png new file mode 100644 index 000000000..0dfe02293 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png new file mode 100644 index 000000000..3eac6a9b0 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png new file mode 100644 index 000000000..3678fb144 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png new file mode 100644 index 000000000..07bdaef80 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png new file mode 100644 index 000000000..2e677e352 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-41.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-41.png new file mode 100644 index 000000000..2e677e352 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-41.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-42.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-42.png new file mode 100644 index 000000000..2e677e352 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-42.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png new file mode 100644 index 000000000..d12b28b68 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-59.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-59.png new file mode 100644 index 000000000..d12b28b68 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-59.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png new file mode 100644 index 000000000..f39ae88b0 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png new file mode 100644 index 000000000..c19306559 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png new file mode 100644 index 000000000..7c3c3cc4e Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-81.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-81.png new file mode 100644 index 000000000..7c3c3cc4e Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-81.png differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png new file mode 100644 index 000000000..68e270346 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png differ diff --git a/submodules/RSCore b/submodules/RSCore index 23e307542..277c64868 160000 --- a/submodules/RSCore +++ b/submodules/RSCore @@ -1 +1 @@ -Subproject commit 23e307542e5c65b3fbe4c3a022a97570c176a900 +Subproject commit 277c64868a4a7464f32bbd2b063af3b7736904a3 diff --git a/submodules/RSWeb b/submodules/RSWeb index afcbd0819..bbb58ff2a 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit afcbd0819c85b263acc892361ed840a9628eba4d +Subproject commit bbb58ff2afb539ff65816793754933ae9db8f259