Merge branch 'master' of https://github.com/brentsimmons/NetNewsWire
85
AppleScript/Mail-CreateOutgoingMessage.applescript
Normal 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
@ -0,0 +1,2 @@
|
||||
Sample AppleScript scripts go in this folder.
|
||||
|
@ -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 don’t, just take the last name and make sure there’s a Folder.
|
||||
|
||||
@ -437,14 +441,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||
}
|
||||
|
||||
func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed {
|
||||
|
||||
let metadata = feedMetadata(feedURL: url, feedID: feedID)
|
||||
let feed = Feed(account: self, url: url, metadata: metadata)
|
||||
feed.name = name
|
||||
feed.homePageURL = homePageURL
|
||||
|
||||
return feed
|
||||
|
||||
}
|
||||
|
||||
public func removeFeed(_ feed: Feed, from container: Container?, completion: @escaping (Result<Void, 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 feed’s unread count.
|
||||
|
||||
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
|
||||
if article.feed == feed && !article.status.read {
|
||||
return result + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
feed.unreadCount = feedUnreadCount
|
||||
}
|
||||
|
||||
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
|
||||
|
||||
let startOfToday = NSCalendar.startOfToday()
|
||||
database.fetchUnreadCount(for: flattenedFeeds().feedIDs(), since: startOfToday, callback: callback)
|
||||
}
|
||||
|
||||
public func fetchUnreadCountForStarredArticles(_ callback: @escaping (Int) -> Void) {
|
||||
|
||||
database.fetchStarredAndUnreadCount(for: flattenedFeeds().feedIDs(), callback: callback)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticleIDs() -> Set<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 feed’s unread count.
|
||||
|
||||
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
|
||||
if article.feed == feed && !article.status.read {
|
||||
return result + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
feed.unreadCount = feedUnreadCount
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Disk (Private)
|
||||
|
||||
private extension Account {
|
||||
@ -930,11 +1001,9 @@ private extension Account {
|
||||
BatchUpdate.shared.perform {
|
||||
loadOPMLItems(children, parentFolder: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func saveToDisk() {
|
||||
|
||||
dirty = false
|
||||
|
||||
let opmlDocumentString = opmlDocument()
|
||||
@ -1032,7 +1101,6 @@ private extension Account {
|
||||
}
|
||||
|
||||
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
|
||||
|
||||
var feedsToAdd = Set<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 won’t appear in unreadCountDictionary.
|
||||
|
||||
if let unreadCount = unreadCountDictionary[feed.feedID] {
|
||||
@ -1128,10 +1193,8 @@ private extension Account {
|
||||
extension Account {
|
||||
|
||||
public func existingFeed(with feedID: String) -> Feed? {
|
||||
|
||||
return idToFeedDictionary[feedID]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - OPMLRepresentable
|
||||
@ -1139,7 +1202,6 @@ extension Account {
|
||||
extension Account: OPMLRepresentable {
|
||||
|
||||
public func OPMLString(indentLevel: Int) -> String {
|
||||
|
||||
var s = ""
|
||||
for feed in topLevelFeeds {
|
||||
s += feed.OPMLString(indentLevel: indentLevel + 1)
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// AccountDelegate.swift
|
||||
// Account
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// AccountError.swift
|
||||
// Account
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
@ -19,9 +19,9 @@ public enum AccountError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .createErrorNotFound:
|
||||
return NSLocalizedString("The feed couldn't be found and can't be added.", comment: "Not found")
|
||||
return NSLocalizedString("The feed couldn’t be found and can’t be added.", comment: "Not found")
|
||||
case .createErrorAlreadySubscribed:
|
||||
return NSLocalizedString("You are already subscribed to this feed and can't add it again.", comment: "Already subscribed")
|
||||
return NSLocalizedString("You are already subscribed to this feed and can’t add it again.", comment: "Already subscribed")
|
||||
case .opmlImportInProgress:
|
||||
return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running")
|
||||
case .wrappedError(let error, let account):
|
||||
@ -65,5 +65,4 @@ public enum AccountError: LocalizedError {
|
||||
let localizedText = NSLocalizedString("An error occurred while processing the \"%@\" account: %@", comment: "Unknown error")
|
||||
return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -77,5 +77,4 @@ final class AccountMetadata: Codable {
|
||||
func valueDidChange(_ key: CodingKeys) {
|
||||
delegate?.valueDidChange(self, key: key)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -76,7 +76,6 @@ public extension Container {
|
||||
}
|
||||
|
||||
func flattenedFeeds() -> Set<Feed> {
|
||||
|
||||
var feeds = Set<Feed>()
|
||||
feeds.formUnion(topLevelFeeds)
|
||||
if let folders = folders {
|
||||
|
@ -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.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// DataExtensions.swift
|
||||
// Account
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/7/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Feed.swift
|
||||
// DataModel
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -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 there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source.
|
||||
|
||||
if let existingFeedSpecifier = feedSpecifiers[feedSpecifier.urlString] {
|
||||
@ -72,7 +66,6 @@ private extension FeedFinder {
|
||||
}
|
||||
|
||||
static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result<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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// HTMLFeedFinder.swift
|
||||
// FeedFinder
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/7/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
@ -20,7 +20,6 @@ class HTMLFeedFinder {
|
||||
private var feedSpecifiersDictionary = [String: FeedSpecifier]()
|
||||
|
||||
init(parserData: ParserData) {
|
||||
|
||||
let metadata = RSHTMLMetadataParser.htmlMetadata(with: parserData)
|
||||
|
||||
for oneFeedLink in metadata.feedLinks {
|
||||
@ -46,7 +45,6 @@ class HTMLFeedFinder {
|
||||
private extension HTMLFeedFinder {
|
||||
|
||||
func addFeedSpecifier(_ feedSpecifier: FeedSpecifier) {
|
||||
|
||||
// If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source.
|
||||
|
||||
if let existingFeedSpecifier = feedSpecifiersDictionary[feedSpecifier.urlString] {
|
||||
@ -59,7 +57,6 @@ private extension HTMLFeedFinder {
|
||||
}
|
||||
|
||||
func urlStringMightBeFeed(_ urlString: String) -> Bool {
|
||||
|
||||
let massagedURLString = urlString.replacingOccurrences(of: "buzzfeed", with: "_")
|
||||
|
||||
for oneMatch in feedURLWordsToMatch {
|
||||
@ -73,7 +70,6 @@ private extension HTMLFeedFinder {
|
||||
}
|
||||
|
||||
func linkMightBeFeed(_ link: RSHTMLLink) -> Bool {
|
||||
|
||||
if let linkURLString = link.urlString, urlStringMightBeFeed(linkURLString) {
|
||||
return true
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Attachment.swift
|
||||
// DataModel
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Author.swift
|
||||
// DataModel
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -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)
|
||||
|
@ -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>? {
|
||||
|
@ -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 */,
|
||||
|
@ -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> {
|
||||
|
||||
// Don’t fetch articles that shouldn’t appear in the UI. The rules:
|
||||
// * Must not be deleted.
|
||||
// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
|
||||
@ -443,7 +530,6 @@ private extension ArticlesTable {
|
||||
}
|
||||
|
||||
func fetchUnreadCount(_ feedID: String, _ database: FMDatabase) -> Int {
|
||||
|
||||
// Count only the articles that would appear in the UI.
|
||||
// * Must be unread.
|
||||
// * Must not be deleted.
|
||||
@ -453,97 +539,6 @@ private extension ArticlesTable {
|
||||
return numberWithSQLAndParameters(sql, [feedID, articleCutoffDate], in: database)
|
||||
}
|
||||
|
||||
func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set<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) })
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Keys.swift
|
||||
// Database
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/3/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// DatabaseArticle.swift
|
||||
// Database
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/21/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// ParsedArticle+Database.swift
|
||||
// Database
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/18/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
72
Mac/MainWindow/Timeline/FetchRequestOperation.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
54
Mac/MainWindow/Timeline/FetchRequestQueue.swift
Normal 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 }
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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) we’re not displaying featured images anywhere,
|
||||
// so let’s skip downloading them even if we find them.
|
||||
//
|
||||
// We’ll revisit this later.
|
||||
|
||||
if let url = article.imageURL {
|
||||
if let imageData = appDelegate.imageDownloader.image(for: url) {
|
||||
return NSImage(data: imageData)
|
||||
}
|
||||
}
|
||||
// if let url = article.imageURL {
|
||||
// if let imageData = appDelegate.imageDownloader.image(for: url) {
|
||||
// return NSImage(data: imageData)
|
||||
// }
|
||||
// }
|
||||
|
||||
return nil
|
||||
|
||||
@ -838,7 +851,6 @@ private extension TimelineViewController {
|
||||
}
|
||||
|
||||
func emptyTheTimeline() {
|
||||
|
||||
if !articles.isEmpty {
|
||||
articles = [Article]()
|
||||
}
|
||||
@ -848,7 +860,7 @@ private extension TimelineViewController {
|
||||
|
||||
performBlockAndRestoreSelection {
|
||||
let unsortedArticles = Set(articles)
|
||||
updateArticles(with: unsortedArticles)
|
||||
replaceArticles(with: unsortedArticles)
|
||||
}
|
||||
}
|
||||
|
||||
@ -915,18 +927,39 @@ private extension TimelineViewController {
|
||||
|
||||
// MARK: Fetching Articles
|
||||
|
||||
func fetchArticles() {
|
||||
|
||||
func fetchAndReplaceArticlesSync() {
|
||||
// To be called when the user has made a change of selection in the sidebar.
|
||||
// It blocks the main thread, so that there’s no async delay,
|
||||
// so that the entire display refreshes at once.
|
||||
// It’s a better user experience this way.
|
||||
cancelPendingAsyncFetches()
|
||||
guard let representedObjects = representedObjects else {
|
||||
emptyTheTimeline()
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedArticles = fetchUnsortedArticles(for: representedObjects)
|
||||
updateArticles(with: fetchedArticles)
|
||||
let fetchedArticles = fetchUnsortedArticlesSync(for: representedObjects)
|
||||
replaceArticles(with: fetchedArticles)
|
||||
}
|
||||
|
||||
func updateArticles(with unsortedArticles: Set<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 it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t get called.
|
||||
precondition(Thread.isMainThread)
|
||||
cancelPendingAsyncFetches()
|
||||
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, representedObjects: representedObjects) { [weak self] (articles, operation) in
|
||||
precondition(Thread.isMainThread)
|
||||
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
|
||||
return
|
||||
}
|
||||
callback(articles)
|
||||
}
|
||||
fetchRequestQueue.add(fetchOperation)
|
||||
}
|
||||
|
||||
func selectArticles(_ articleIDs: [String]) {
|
||||
|
||||
let indexesToSelect = indexesForArticleIDs(Set(articleIDs))
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 */,
|
||||
|
@ -13,7 +13,7 @@ import UIKit
|
||||
#elseif os(watchOS)
|
||||
import WatchKit
|
||||
#elseif os(OSX)
|
||||
import Cocoa
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public class ColorHash {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
38
Shared/SmartFeeds/SmartFeedDelegate.swift
Normal 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()) }
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
21
Technotes/SparkleUpdateError.markdown
Normal file
@ -0,0 +1,21 @@
|
||||
# “Can’t Update” Error
|
||||
|
||||
If NetNewsWire’s auto-updater gives you an error that it can’t be updated, do this:
|
||||
|
||||
* Cancel the update if you still need to
|
||||
* Quit NetNewsWire
|
||||
* Move NetNewsWire to your Applications folder (or to your `~/Applications/` folder)
|
||||
* Launch NetNewsWire
|
||||
* Check for Updates again
|
||||
|
||||
That should do the trick!
|
||||
|
||||
## The problem
|
||||
|
||||
If you’re running the app from your `~/Downloads` folder, then the system has placed your app under quarantine — which means the app can’t update itself.
|
||||
|
||||
Once you move the app to another folder, the quarantine is lifted, and the app can update itself.
|
||||
|
||||
This *does* require your manual intervention: it‘s not something NetNewsWire can do for you automatically.
|
||||
|
||||
For more info, [see this bug](https://github.com/brentsimmons/NetNewsWire/issues/213).
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 612 B |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 876 B |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 7.4 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
After Width: | Height: | Size: 775 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-121.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-41.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-42.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-59.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-81.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png
Normal file
After Width: | Height: | Size: 7.5 KiB |