This commit is contained in:
Maurice Parker 2019-07-12 12:31:36 -05:00
commit 8e278ea843
102 changed files with 1143 additions and 969 deletions

View File

@ -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

2
AppleScript/README.md Normal file
View File

@ -0,0 +1,2 @@
Sample AppleScript scripts go in this folder.

View File

@ -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<String>)
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, Error>) -> 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 dont, just take the last name and make sure theres 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, Error>) -> 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<Feed>) {
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<String>) -> Set<Article> {
return database.fetchArticles(forArticleIDs: articleIDs)
}
public func fetchArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(for: feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(for: Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
public func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
public func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(for: feeds.feedIDs())
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
articles.forEach { (article) in
precondition(!article.status.read)
unreadCountStorage[article.feedID, default: 0] += 1
public func fetchArticles(_ fetchType: FetchType) -> Set<Article> {
switch fetchType {
case .starred:
return fetchStarredArticles()
case .unread:
return fetchUnreadArticles()
case .today:
return fetchTodayArticles()
case .unreadForFolder(let folder):
return fetchArticles(folder: folder)
case .feed(let feed):
return fetchArticles(feed: feed)
case .articleIDs(let articleIDs):
return fetchArticles(articleIDs: articleIDs)
case .search(let searchString):
return fetchArticlesMatching(searchString)
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
}
public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) {
switch fetchType {
case .starred:
fetchStarredArticlesAsync(callback)
case .unread:
fetchUnreadArticlesAsync(callback)
case .today:
fetchTodayArticlesAsync(callback)
case .unreadForFolder(let folder):
fetchArticlesAsync(folder: folder, callback)
case .feed(let feed):
fetchArticlesAsync(feed: feed, callback)
case .articleIDs(let articleIDs):
fetchArticlesAsync(articleIDs: articleIDs, callback)
case .search(let searchString):
fetchArticlesMatchingAsync(searchString, callback)
}
return articles
}
public func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(for: flattenedFeeds().feedIDs())
}
public func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(for: flattenedFeeds().feedIDs())
}
public func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, for: flattenedFeeds().feedIDs())
}
private func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
}
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
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<String> {
return database.fetchUnreadArticleIDs()
public func fetchUnreadArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
database.fetchUnreadArticleIDs(callback)
}
public func fetchStarredArticleIDs() -> Set<String> {
return database.fetchStarredArticleIDs()
public func fetchStarredArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
database.fetchStarredArticleIDs(callback)
}
public func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
return database.fetchArticleIDsForStatusesWithoutArticles()
public func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set<String>) -> 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<ParsedItem>, 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<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
// 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<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool) {
database.ensureStatuses(articleIDs, statusKey, flag)
if !articleIDs.isEmpty {
database.ensureStatuses(articleIDs, statusKey, flag)
}
}
// MARK: - Container
public func flattenedFeeds() -> Set<Feed> {
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<Article> {
return database.fetchStarredArticles(flattenedFeeds().feedIDs())
}
func fetchStarredArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchedStarredArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: self, callback)
}
func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(flattenedFeeds().feedIDs())
}
func fetchTodayArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchTodayArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
func fetchArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: folder, callback)
}
func fetchArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
func fetchArticlesAsync(feed: Feed, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesAsync(feed.feedID) { [weak self] (articles) in
self?.validateUnreadCount(feed, articles)
callback(articles)
}
}
func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs())
}
func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return database.fetchArticles(articleIDs: articleIDs)
}
func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
return database.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
func fetchUnreadArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
func fetchUnreadArticlesAsync(for feed: Feed, callback: @escaping (Set<Article>) -> Void) {
// database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in
// self?.validateUnreadCount(feed, articles)
// callback(articles)
// }
}
func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(feeds.feedIDs())
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
return articles
}
func fetchUnreadArticlesAsync(forContainer container: Container, _ callback: @escaping ArticleSetBlock) {
let feeds = container.flattenedFeeds()
database.fetchUnreadArticlesAsync(feeds.feedIDs()) { [weak self] (articles) in
self?.validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
callback(articles)
}
}
func validateUnreadCountsAfterFetchingUnreadArticles(_ feeds: Set<Feed>, _ articles: Set<Article>) {
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
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<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
}
}
// MARK: - Disk (Private)
private extension Account {
@ -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<Feed>()
items.forEach { (item) in
@ -1082,7 +1150,6 @@ private extension Account {
}
func noteStatusesForArticlesDidChange(_ articles: Set<Article>) {
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 wont 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)

View File

@ -1,6 +1,6 @@
//
// AccountDelegate.swift
// Account
// NetNewsWire
//
// Created by Brent Simmons on 9/16/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.

View File

@ -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 couldnt be found and cant 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 cant 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
}
}

View File

@ -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
@ -198,15 +191,41 @@ public final class AccountManager: UnreadCountProvider {
return false
}
func updateUnreadCount() {
// MARK: - Fetching Articles
unreadCount = calculateUnreadCount(activeAccounts)
// These fetch articles from active accounts and return a merged Set<Article>.
public func fetchArticles(_ fetchType: FetchType) -> Set<Article> {
precondition(Thread.isMainThread)
var articles = Set<Article>()
for account in activeAccounts {
articles.formUnion(account.fetchArticles(fetchType))
}
return articles
}
// MARK: Notifications
public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) {
precondition(Thread.isMainThread)
var allFetchedArticles = Set<Article>()
let numberOfAccounts = activeAccounts.count
var accountsReporting = 0
for account in activeAccounts {
account.fetchArticlesAsync(fetchType) { (articles) in
allFetchedArticles.formUnion(articles)
accountsReporting += 1
if accountsReporting == numberOfAccounts {
callback(allFetchedArticles)
}
}
}
}
// MARK: - Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) {
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
// MARK: - Private
private func loadAccount(_ accountSpecifier: AccountSpecifier) -> Account? {
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)
}
}

View File

@ -77,5 +77,4 @@ final class AccountMetadata: Codable {
func valueDidChange(_ key: CodingKeys) {
delegate?.valueDidChange(self, key: key)
}
}

View File

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

View File

@ -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

View File

@ -76,7 +76,6 @@ public extension Container {
}
func flattenedFeeds() -> Set<Feed> {
var feeds = Set<Feed>()
feeds.formUnion(topLevelFeeds)
if let folders = folders {

View File

@ -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.

View File

@ -1,6 +1,6 @@
//
// DataExtensions.swift
// Account
// NetNewsWire
//
// Created by Brent Simmons on 10/7/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.

View File

@ -1,6 +1,6 @@
//
// Feed.swift
// DataModel
// NetNewsWire
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.

View File

@ -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<Set<FeedSpecifier>, 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 theres an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesnt, 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<Set<FeedSpecifier>, Error>) -> Void) {
// Feeds in the <head> section we automatically assume are feeds.
// If there are none from the <head> section,
// then possible feeds in <body> 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<FeedSpecifier> {
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)
}
}

View File

@ -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>) -> 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 {

View File

@ -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 theres an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesnt, 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
}

View File

@ -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)
}
}

View File

@ -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,6 +687,7 @@ 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)
@ -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)
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
for chunk in chunkedArticleIDs {
switch result {
case .success(let entries):
group.enter()
caller.retrieveEntries(articleIDs: chunk) { result in
self.processEntries(account: account, entries: entries) {
group.leave()
}
switch result {
case .success(let entries):
self.processEntries(account: account, entries: entries) {
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()
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)
// 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 {
// 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)
}
// 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)
if !missingReadArticleIDs.isEmpty {
DispatchQueue.main.async {
// 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()
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)
// 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 {
// 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)
}
// 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)
if !missingUnstarredArticleIDs.isEmpty {
DispatchQueue.main.async {
// 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, Error>) -> Void) {

View File

@ -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 = "<outline text=\"\(escapedTitle)\" title=\"\(escapedTitle)\">\n"
s = s.rs_string(byPrependingNumberOfTabs: indentLevel)

View File

@ -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

View File

@ -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, Error>) -> 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<Feed, Error>) -> 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<Credentials?, Error>) -> Void) {
return completion(.success(nil))
}
}

View File

@ -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<Feed>) {
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()
}

View File

@ -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)
account.fetchArticleIDsForStatusesWithoutArticles { (fetchedArticleIDs) in
let articleIDs = Array(fetchedArticleIDs)
let chunkedArticleIDs = articleIDs.chunked(into: 100)
for chunk in chunkedArticleIDs {
for chunk in chunkedArticleIDs {
group.enter()
self.caller.retrieveEntries(articleIDs: chunk) { result in
group.enter()
caller.retrieveEntries(articleIDs: chunk) { result in
switch result {
case .success(let entries):
self.processEntries(account: account, entries: entries) {
group.leave()
}
switch result {
case .success(let entries):
self.processEntries(account: account, entries: entries) {
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()
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)
// 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 {
// 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)
}
// 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)
if !missingReadArticleIDs.isEmpty {
DispatchQueue.main.async {
// 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()
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)
// 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 {
// 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)
}
// 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)
if !missingUnstarredArticleIDs.isEmpty {
DispatchQueue.main.async {
// 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, Error>) -> Void) {

View File

@ -25,12 +25,10 @@ public protocol UnreadCountProvider {
public extension UnreadCountProvider {
func postUnreadCountDidChangeNotification() {
NotificationCenter.default.post(name: .UnreadCountDidChange, object: self, userInfo: nil)
}
func calculateUnreadCount<T: Collection>(_ children: T) -> Int {
let updatedUnreadCount = children.reduce(0) { (result, oneChild) -> Int in
if let oneUnreadCountProvider = oneChild as? UnreadCountProvider {
return result + oneUnreadCountProvider.unreadCount

View File

@ -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<Article>) -> Void
public struct Article: Hashable {
public let articleID: String // Unique database ID (possibly sync service ID)

View File

@ -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,7 +79,6 @@ public final class ArticleStatus: Hashable {
public extension Set where Element == ArticleStatus {
func articleIDs() -> Set<String> {
return Set<String>(map { $0.articleID })
}
}
@ -92,7 +86,6 @@ public extension Set where Element == ArticleStatus {
public extension Array where Element == ArticleStatus {
func articleIDs() -> [String] {
return map { $0.articleID }
}
}

View File

@ -1,6 +1,6 @@
//
// Attachment.swift
// DataModel
// NetNewsWire
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.

View File

@ -1,6 +1,6 @@
//
// Author.swift
// DataModel
// NetNewsWire
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.

View File

@ -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)

View File

@ -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<Article>) -> Void
// Main thread only.
public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount
public typealias UnreadCountCompletionBlock = (UnreadCountDictionary) -> Void
public typealias UpdateArticlesWithFeedCompletionBlock = (Set<Article>?, Set<Article>?) -> Void //newArticles, updatedArticles
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<Article> {
public func fetchArticles(_ feedID: String) -> Set<Article> {
return articlesTable.fetchArticles(feedID)
}
public func fetchArticles(forArticleIDs articleIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticles(forArticleIDs: articleIDs)
public func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticles(articleIDs: articleIDs)
}
public func fetchArticlesAsync(for feedID: String, _ resultBlock: @escaping ArticleResultBlock) {
articlesTable.fetchArticlesAsync(feedID, withLimits: true, resultBlock)
public func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchUnreadArticles(feedIDs)
}
public func fetchUnreadArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchUnreadArticles(for: feedIDs)
public func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchTodayArticles(feedIDs)
}
public func fetchTodayArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchTodayArticles(for: feedIDs)
public func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchStarredArticles(feedIDs)
}
public func fetchStarredArticles(for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchStarredArticles(for: feedIDs)
public func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticlesMatching(searchString, feedIDs)
}
public func fetchArticlesMatching(_ searchString: String, for feedIDs: Set<String>) -> Set<Article> {
return articlesTable.fetchArticlesMatching(searchString, for: feedIDs)
// MARK: - Fetching Articles Async
public func fetchArticlesAsync(_ feedID: String, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesAsync(feedID, callback)
}
public func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
public func fetchUnreadArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchUnreadArticlesAsync(feedIDs, callback)
}
public func fetchTodayArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchTodayArticlesAsync(feedIDs, callback)
}
public func fetchedStarredArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchStarredArticlesAsync(feedIDs, callback)
}
public func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
articlesTable.fetchArticlesMatchingAsync(searchString, feedIDs, callback)
}
// MARK: - Unread Counts
public func fetchUnreadCounts(for feedIDs: Set<String>, _ completion: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchUnreadCounts(feedIDs, completion)
public func fetchUnreadCounts(for feedIDs: Set<String>, _ callback: @escaping UnreadCountCompletionBlock) {
articlesTable.fetchUnreadCounts(feedIDs, callback)
}
public func fetchUnreadCount(for feedIDs: Set<String>, since: Date, callback: @escaping (Int) -> Void) {
@ -88,8 +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<String> {
return articlesTable.fetchUnreadArticleIDs()
public func fetchUnreadArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
articlesTable.fetchUnreadArticleIDs(callback)
}
public func fetchStarredArticleIDs() -> Set<String> {
return articlesTable.fetchStarredArticleIDs()
public func fetchStarredArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
articlesTable.fetchStarredArticleIDs(callback)
}
public func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
return articlesTable.fetchArticleIDsForStatusesWithoutArticles()
public func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set<String>) -> Void) {
articlesTable.fetchArticleIDsForStatusesWithoutArticles(callback)
}
public func mark(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<ArticleStatus>? {

View File

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

View File

@ -29,6 +29,8 @@ final class ArticlesTable: DatabaseTable {
private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)!
private var maximumArticleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 4 * 31)!
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
init(name: String, accountID: String, queue: RSDatabaseQueue) {
self.name = name
@ -43,52 +45,109 @@ final class ArticlesTable: DatabaseTable {
self.attachmentsLookupTable = DatabaseLookupTable(name: DatabaseTableName.attachmentsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.attachmentID, relatedTable: attachmentsTable, relationshipName: RelationshipName.attachments)
}
// MARK: Fetching
// MARK: - Fetching Articles for Feed
func fetchArticles(_ feedID: String) -> Set<Article> {
return fetchArticles{ self.fetchArticlesForFeedID(feedID, withLimits: true, $0) }
}
var articles = Set<Article>()
func fetchArticlesAsync(_ feedID: String, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchArticlesForFeedID(feedID, withLimits: true, $0) }, callback)
}
queue.fetchSync { (database) in
articles = self.fetchArticlesForFeedID(feedID, withLimits: true, database: database)
private func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, _ database: FMDatabase) -> Set<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits)
}
// MARK: - Fetching Articles by articleID
func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchArticles(articleIDs: articleIDs, $0) }
}
func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
return fetchArticlesAsync({ self.fetchArticles(articleIDs: articleIDs, $0) }, callback)
}
private func fetchArticles(articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
}
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let whereClause = "articleID in \(placeholders)"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
// MARK: - Fetching Unread Articles
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchUnreadArticles(feedIDs, $0) }
}
func fetchUnreadArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchUnreadArticles(feedIDs, $0) }, callback)
}
private func fetchUnreadArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
if feedIDs.isEmpty {
return Set<Article>()
}
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and read=0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
}
// MARK: - Fetching Today Articles
func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchTodayArticles(feedIDs, $0) }
}
func fetchTodayArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchTodayArticles(feedIDs, $0) }, callback)
}
private func fetchTodayArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?)
//
// datePublished may be nil, so we fall back to dateArrived.
if feedIDs.isEmpty {
return Set<Article>()
}
let startOfToday = NSCalendar.startOfToday()
let parameters = feedIDs.map { $0 as AnyObject } + [startOfToday as AnyObject, startOfToday as AnyObject]
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and userDeleted = 0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
// MARK: - Fetching Starred Articles
func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
return fetchArticles{ self.fetchStarredArticles(feedIDs, $0) }
}
func fetchStarredArticlesAsync(_ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchStarredArticles(feedIDs, $0) }, callback)
}
private func fetchStarredArticles(_ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred = 1 and userDeleted = 0;
if feedIDs.isEmpty {
return Set<Article>()
}
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and starred = 1 and userDeleted = 0"
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
// MARK: - Fetching Search Articles
public func fetchArticles(forArticleIDs articleIDs: Set<String>) -> Set<Article> {
return fetchArticlesForIDs(articleIDs)
}
func fetchArticlesAsync(_ feedID: String, withLimits: Bool, _ resultBlock: @escaping ArticleResultBlock) {
queue.fetch { (database) in
let articles = self.fetchArticlesForFeedID(feedID, withLimits: withLimits, database: database)
DispatchQueue.main.async {
resultBlock(articles)
}
}
}
func fetchUnreadArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchUnreadArticles(feedIDs)
}
public func fetchTodayArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchTodayArticles(feedIDs)
}
public func fetchStarredArticles(for feedIDs: Set<String>) -> Set<Article> {
return fetchStarredArticles(feedIDs)
}
func fetchArticlesMatching(_ searchString: String, for feedIDs: Set<String>) -> Set<Article> {
func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>) -> Set<Article> {
var articles: Set<Article> = Set<Article>()
queue.fetchSync { (database) in
articles = self.fetchArticlesMatching(searchString, database)
@ -97,6 +156,32 @@ final class ArticlesTable: DatabaseTable {
return articles
}
func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
fetchArticlesAsync({ self.fetchArticlesMatching(searchString, feedIDs, $0) }, callback)
}
private func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
let sql = "select rowid from search where search match ?;"
let sqlSearchString = sqliteSearchString(with: searchString)
let searchStringParameters = [sqlSearchString]
guard let resultSet = database.executeQuery(sql, withArgumentsIn: searchStringParameters) else {
return Set<Article>()
}
let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) }
if searchRowIDs.isEmpty {
return Set<Article>()
}
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
let whereClause = "searchRowID in \(placeholders)"
let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject]
let articles = fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
// TODO: include the feedIDs in the SQL rather than filtering here.
return articles.filter{ feedIDs.contains($0.feedID) }
}
// MARK: - Fetching Articles for Indexer
func fetchArticleSearchInfos(_ articleIDs: Set<String>, in database: FMDatabase) -> Set<ArticleSearchInfo>? {
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
@ -122,10 +207,9 @@ final class ArticlesTable: DatabaseTable {
return nil
}
// MARK: Updating
// MARK: - Updating
func update(_ feedID: String, _ parsedItems: Set<ParsedItem>, _ 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<String>, _ 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<String>, _ 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<String>, _ 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<String>, _ 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<String> {
return statusesTable.fetchUnreadArticleIDs()
func fetchUnreadArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
statusesTable.fetchUnreadArticleIDs(callback)
}
func fetchStarredArticleIDs() -> Set<String> {
return statusesTable.fetchStarredArticleIDs()
func fetchStarredArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
statusesTable.fetchStarredArticleIDs(callback)
}
func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
return statusesTable.fetchArticleIDsForStatusesWithoutArticles()
func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set<String>) -> Void) {
statusesTable.fetchArticleIDsForStatusesWithoutArticles(callback)
}
func mark(_ articles: Set<Article>, _ statusKey: ArticleStatus.Key, _ flag: Bool) -> Set<ArticleStatus>? {
@ -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<Article> {
var articles = Set<Article>()
queue.fetchSync { (database) in
articles = fetchMethod(database)
}
return articles
}
private func fetchArticlesAsync(_ fetchMethod: @escaping ArticlesFetchMethod, _ callback: @escaping ArticleSetBlock) {
queue.fetch { (database) in
let articles = fetchMethod(database)
DispatchQueue.main.async {
callback(articles)
}
}
}
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
// 1. Create DatabaseArticles without related objects.
// 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<DatabaseArticle> {
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<Article> {
// Dont fetch articles that shouldnt 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<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits)
}
func fetchArticlesForIDs(_ articleIDs: Set<String>) -> Set<Article> {
if articleIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let whereClause = "articleID in \(placeholders)"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and read=0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
}
return articles
}
func fetchTodayArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?)
//
// datePublished may be nil, so we fall back to dateArrived.
let startOfToday = NSCalendar.startOfToday()
let parameters = feedIDs.map { $0 as AnyObject } + [startOfToday as AnyObject, startOfToday as AnyObject]
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and userDeleted = 0"
// let whereClause = "feedID in \(placeholders) and datePublished > ? and userDeleted = 0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchStarredArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred = 1 and userDeleted = 0;
let parameters = feedIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let whereClause = "feedID in \(placeholders) and starred = 1 and userDeleted = 0"
articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
}
return articles
}
func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
let sql = "select rowid from search where search match ?;"
let sqlSearchString = sqliteSearchString(with: searchString)
@ -578,33 +573,28 @@ private extension ArticlesTable {
}
func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set<Article> {
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
return Set<Article>()
}
return articlesWithResultSet(resultSet, database)
}
// MARK: Saving Parsed Items
// MARK: - Saving Parsed Items
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
DispatchQueue.main.async {
completion(newArticles, updatedArticles)
}
}
// MARK: Save New Articles
// MARK: - Saving New Articles
func findNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
let newArticles = Set(incomingArticles.filter { fetchedArticlesDictionary[$0.articleID] == nil })
return newArticles.isEmpty ? nil : newArticles
}
func findAndSaveNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //5
guard let newArticles = findNewArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
@ -613,7 +603,6 @@ private extension ArticlesTable {
}
func saveNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
saveRelatedObjectsForNewArticles(articles, database)
if let databaseDictionaries = articles.databaseDictionaries() {
@ -622,17 +611,15 @@ private extension ArticlesTable {
}
func saveRelatedObjectsForNewArticles(_ articles: Set<Article>, _ 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<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article]) -> Set<Article> {
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<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ 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<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
updateRelatedObjects(\Article.authors, updatedArticles, fetchedArticles, authorsLookupTable, database)
updateRelatedObjects(\Article.attachments, updatedArticles, fetchedArticles, attachmentsLookupTable, database)
}
func findUpdatedArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
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<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //6
guard let updatedArticles = findUpdatedArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
@ -681,7 +664,6 @@ private extension ArticlesTable {
func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ 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<Article>) -> Set<Article> {
// Drop Articles that we can ignore.
return Set(articles.filter{ !statusIndicatesArticleIsIgnorable($0.status) })
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,6 +1,6 @@
//
// Keys.swift
// Database
// NetNewsWire
//
// Created by Brent Simmons on 7/3/17.
// Copyright © 2017 Ranchero Software. All rights reserved.

View File

@ -1,6 +1,6 @@
//
// DatabaseArticle.swift
// Database
// NetNewsWire
//
// Created by Brent Simmons on 9/21/17.
// Copyright © 2017 Ranchero Software. All rights reserved.

View File

@ -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<Author>? {
let authors = Set(self.map { $0 as! Author })
return authors.isEmpty ? nil : authors
}
func asAttachments() -> Set<Attachment>? {
let attachments = Set(self.map { $0 as! Attachment })
return attachments.isEmpty ? nil : attachments
}

View File

@ -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<Author>?, attachments: Set<Attachment>?) {
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<T: DatabaseObject>(with objects: Set<T>?) -> [DatabaseObject]? {
guard let objects = objects else {
return nil
}
@ -158,12 +154,10 @@ extension Article: DatabaseObject {
extension Set where Element == Article {
func statuses() -> Set<ArticleStatus> {
return Set<ArticleStatus>(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() }
}
}

View File

@ -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)

View File

@ -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<ParsedAttachment>?) -> Set<Attachment>? {
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 }
}
}

View File

@ -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<ParsedAuthor>?) -> Set<Author>? {
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

View File

@ -1,6 +1,6 @@
//
// ParsedArticle+Database.swift
// Database
// NetNewsWire
//
// Created by Brent Simmons on 9/18/17.
// Copyright © 2017 Ranchero Software. All rights reserved.

View File

@ -13,7 +13,6 @@ import Articles
extension RelatedObjectsMap {
func attachments(for articleID: String) -> Set<Attachment>? {
if let objects = self[articleID] {
return objects.asAttachments()
}
@ -21,7 +20,6 @@ extension RelatedObjectsMap {
}
func authors(for articleID: String) -> Set<Author>? {
if let objects = self[articleID] {
return objects.asAuthors()
}

View File

@ -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())
}

View File

@ -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<String>, _ 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<ArticleStatus>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set<ArticleStatus>? {
// Sets flag in both memory and in database.
var updatedStatuses = Set<ArticleStatus>()
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<String> {
return fetchArticleIDs("select articleID from statuses where read=0 and userDeleted=0;")
func fetchUnreadArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
fetchArticleIDs("select articleID from statuses where read=0 and userDeleted=0;", callback)
}
func fetchStarredArticleIDs() -> Set<String> {
return fetchArticleIDs("select articleID from statuses where starred=1 and userDeleted=0;")
func fetchStarredArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
fetchArticleIDs("select articleID from statuses where starred=1 and userDeleted=0;", callback)
}
func fetchArticleIDsForStatusesWithoutArticles() -> Set<String> {
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<String>) -> 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<String> {
var statuses: Set<String>? = 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<String>) -> Void) {
queue.fetch { (database) in
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
callback(Set<String>())
return
}
let statuses = resultSet.mapToSet(self.articleIDWithRow)
callback(statuses)
}
return statuses != nil ? statuses! : Set<String>()
}
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>) -> [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<String>) -> Set<String> {
return Set(articleIDs.filter { cache[$0] == nil })
}
// MARK: Creating
// MARK: - Creating
func saveStatuses(_ statuses: Set<ArticleStatus>, _ database: FMDatabase) {
let statusArray = statuses.map { $0.databaseDictionary()! }
self.insertRows(statusArray, insertType: .orIgnore, in: database)
}
func createAndSaveStatusesForArticleIDs(_ articleIDs: Set<String>, _ 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<String>, _ 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<String>, _ 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<ArticleStatus>) {
// Replaces any cached statuses.
for status in statuses {
self[status.articleID] = status
}
}
func addStatusIfNotCached(_ status: ArticleStatus) {
addIfNotCached(Set([status]))
}
func addIfNotCached(_ statuses: Set<ArticleStatus>) {
// Does not replace already cached statuses.
for status in statuses {

View File

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

View File

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

View File

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

View File

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

View File

@ -40,9 +40,14 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner {
}
selectionDidChange(nil)
fetchArticles()
if articles.count > 0 {
tableView.scrollRowToVisible(0)
if showsSearchResults {
fetchAndReplaceArticlesAsync()
}
else {
fetchAndReplaceArticlesSync()
if articles.count > 0 {
tableView.scrollRowToVisible(0)
}
}
}
}
@ -51,6 +56,7 @@ 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,12 +787,16 @@ extension TimelineViewController: NSTableViewDelegate {
}
private func featuredImageFor(_ article: Article) -> NSImage? {
// At this writing (17 June 2019) were not displaying featured images anywhere,
// so lets skip downloading them even if we find them.
//
// Well revisit this later.
if let url = article.imageURL {
if let imageData = appDelegate.imageDownloader.image(for: url) {
return NSImage(data: imageData)
}
}
// 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 theres no async delay,
// so that the entire display refreshes at once.
// Its a better user experience this way.
cancelPendingAsyncFetches()
guard let representedObjects = representedObjects else {
emptyTheTimeline()
return
}
let fetchedArticles = fetchUnsortedArticles(for: representedObjects)
updateArticles(with: fetchedArticles)
let fetchedArticles = fetchUnsortedArticlesSync(for: representedObjects)
replaceArticles(with: fetchedArticles)
}
func updateArticles(with unsortedArticles: Set<Article>) {
func fetchAndReplaceArticlesAsync() {
// To be called when we need to do an entire fetch, but an async delay is okay.
// Example: we have the Today feed selected, and the calendar day just changed.
cancelPendingAsyncFetches()
guard let representedObjects = representedObjects else {
emptyTheTimeline()
return
}
fetchUnsortedArticlesAsync(for: representedObjects) { [weak self] (articles) in
self?.replaceArticles(with: articles)
}
}
func cancelPendingAsyncFetches() {
fetchSerialNumber += 1
fetchRequestQueue.cancelAllRequests()
}
func replaceArticles(with unsortedArticles: Set<Article>) {
let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection)
if articles != sortedArticles {
@ -934,20 +967,35 @@ private extension TimelineViewController {
}
}
func fetchUnsortedArticles(for representedObjects: [Any]) -> Set<Article> {
var fetchedArticles = Set<Article>()
for object in representedObjects {
if let articleFetcher = object as? ArticleFetcher {
fetchedArticles.formUnion(articleFetcher.fetchArticles())
}
func fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set<Article> {
cancelPendingAsyncFetches()
let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher }
if articleFetchers.isEmpty {
return Set<Article>()
}
var fetchedArticles = Set<Article>()
for articleFetcher in articleFetchers {
fetchedArticles.formUnion(articleFetcher.fetchArticles())
}
return fetchedArticles
}
func fetchUnsortedArticlesAsync(for representedObjects: [Any], callback: @escaping ArticleSetBlock) {
// The callback will *not* be called if the fetch is no longer relevant that is,
// if its been superseded by a newer fetch, or the timeline was emptied, etc., it wont get called.
precondition(Thread.isMainThread)
cancelPendingAsyncFetches()
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, representedObjects: representedObjects) { [weak self] (articles, operation) in
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
}
callback(articles)
}
fetchRequestQueue.add(fetchOperation)
}
func selectArticles(_ articleIDs: [String]) {
let indexesToSelect = indexesForArticleIDs(Set(articleIDs))

View File

@ -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

View File

@ -134,6 +134,7 @@
</class>
<class name="author" code="Athr" plural="authors" description="A feed author">
<cocoa class="ScriptableAuthor"/>
<property name="name" code="pnam" type="text" access="r" description="The name of the author">
<cocoa key="name"/>
</property>

View File

@ -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

View File

@ -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

View File

@ -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 = "<group>"; };
84C9FCA02262A1B300D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
84C9FCA32262A1B800D921D6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestQueue.swift; sourceTree = "<group>"; };
84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRequestOperation.swift; sourceTree = "<group>"; };
84CBDDAE1FD3674C005A61AA /* Technotes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Technotes; sourceTree = "<group>"; };
84CC88171FE59CBF00644329 /* SmartFeedsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedsController.swift; sourceTree = "<group>"; };
84D2200922B0BC4B0019E085 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = "<group>"; };
84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStatusBarView.swift; sourceTree = "<group>"; };
84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedDelegate.swift; sourceTree = "<group>"; };
84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = "<group>"; };
84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = "<group>"; };
84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -13,7 +13,7 @@ import UIKit
#elseif os(watchOS)
import WatchKit
#elseif os(OSX)
import Cocoa
import AppKit
#endif
public class ColorHash {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ 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 {
@ -50,16 +51,18 @@ final class UnreadFeed: PseudoFeed {
extension UnreadFeed: ArticleFetcher {
func fetchArticles() -> Set<Article> {
return fetchUnreadArticles()
}
func fetchUnreadArticles() -> Set<Article> {
func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(callback)
}
var articles = Set<Article>()
for account in AccountManager.shared.activeAccounts {
articles.formUnion(account.fetchUnreadArticles())
}
return articles
func fetchUnreadArticles() -> Set<Article> {
return AccountManager.shared.fetchArticles(fetchType)
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
AccountManager.shared.fetchArticlesAsync(fetchType, callback)
}
}

View File

@ -0,0 +1,21 @@
# “Cant Update” Error
If NetNewsWires auto-updater gives you an error that it cant 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 youre running the app from your `~/Downloads` folder, then the system has placed your app under quarantine — which means the app cant 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: its not something NetNewsWire can do for you automatically.
For more info, [see this bug](https://github.com/brentsimmons/NetNewsWire/issues/213).

View File

@ -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"
}
],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Some files were not shown because too many files have changed in this diff Show More