Continue march toward non-optional article.status.

This commit is contained in:
Brent Simmons 2017-09-18 22:00:35 -07:00
parent 6db993075b
commit b28a849af6
25 changed files with 232 additions and 213 deletions

View File

@ -1147,11 +1147,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
842E45CE1ED8C308000A8B52 /* AppConstants.swift in Sources */,
849C64641ED37A5D003D8FC0 /* AppDelegate.swift in Sources */,
842E45DD1ED8C54B000A8B52 /* Browser.swift in Sources */,
849A975B1ED9EB0D007D329B /* ArticleUtilities.swift in Sources */,
849A97891ED9ECEF007D329B /* ArticleStyle.swift in Sources */,
849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */,
849A97541ED9EAC0007D329B /* AddFeedWindowController.swift in Sources */,
849A97791ED9EC04007D329B /* TimelineStringUtilities.swift in Sources */,
849A979F1ED9F130007D329B /* SidebarCell.swift in Sources */,
849A97981ED9EFAA007D329B /* Node-Extensions.swift in Sources */,
@ -1167,12 +1168,11 @@
849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */,
849A978D1ED9EE4D007D329B /* FeedListWindowController.swift in Sources */,
849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */,
842E45CE1ED8C308000A8B52 /* AppConstants.swift in Sources */,
849C64641ED37A5D003D8FC0 /* AppDelegate.swift in Sources */,
849A975C1ED9EB0D007D329B /* DefaultFeedsImporter.swift in Sources */,
849A97781ED9EC04007D329B /* TimelineCellLayout.swift in Sources */,
849A976C1ED9EBC8007D329B /* TimelineTableRowView.swift in Sources */,
849A977B1ED9EC04007D329B /* UnreadIndicatorView.swift in Sources */,
849A97541ED9EAC0007D329B /* AddFeedWindowController.swift in Sources */,
842E45E51ED8C6B7000A8B52 /* MainWindowSplitView.swift in Sources */,
849A976D1ED9EBC8007D329B /* TimelineTableView.swift in Sources */,
849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */,

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Latest</string>
</dict>
</plist>

View File

@ -18,12 +18,26 @@ extension Notification.Name {
static let AppNavigationKeyPressed = Notification.Name("AppNavigationKeyPressedNotification")
}
let viewKey = "view"
let nodeKey = "node"
let objectsKey = "objects"
let articleKey = "article"
struct AppUserInfoKey {
static let view = "view"
static let node = "node"
static let objects = "objects"
static let article = "article"
static let articles = "articles"
static let articleStatus = "status"
static let appNavigation = "key"
}
struct AppDefaultsKey {
static let firstRunDate = "firstRunDate"
static let sidebarFontSize = "sidebarFontSize"
static let timelineFontSize = "timelineFontSize"
static let detailFontSize = "detailFontSize"
static let openInBrowserInBackground = "openInBrowserInBackground"
}
let articlesKey = "articles"
let articleStatusKey = "statusKey"
let appNavigationKey = "keyKey"

View File

@ -41,7 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations {
super.init()
}
// MARK: NSApplicationDelegate
// MARK: - NSApplicationDelegate
func applicationDidFinishLaunching(_ note: Notification) {
@ -51,11 +51,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations {
let _ = AccountManager.sharedInstance
let kFirstRunDateKey = "firstRun"
var isFirstRun = false
if UserDefaults.standard.object(forKey: kFirstRunDateKey) == nil {
if UserDefaults.standard.object(forKey: AppDefaultsKey.firstRunDate) == nil {
isFirstRun = true
UserDefaults.standard.set(Date(), forKey: kFirstRunDateKey)
UserDefaults.standard.set(Date(), forKey: AppDefaultsKey.firstRunDate)
}
importDefaultFeedsIfNeeded(isFirstRun, account: AccountManager.sharedInstance.localAccount)
@ -224,7 +223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations {
panel.allowsOtherFileTypes = false
let result = panel.runModal()
if result == NSFileHandlingPanelOKButton {
if result == NSApplication.ModalResponse.OK {
if let url = panel.url {
DispatchQueue.main.async {
self.parseAndImportOPML(url, AccountManager.sharedInstance.localAccount)
@ -250,15 +249,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations {
panel.nameFieldStringValue = "MySubscriptions.opml"
let result = panel.runModal()
if result == NSFileHandlingPanelOKButton {
if result.rawValue == NSFileHandlingPanelOKButton {
if let url = panel.url {
DispatchQueue.main.async {
let opmlString = AccountManager.sharedInstance.localAccount.opmlString(indentLevel: 0)
let opmlString = AccountManager.sharedInstance.localAccount.OPMLString(indentLevel: 0)
do {
try opmlString.write(to: url, atomically: true, encoding: String.Encoding.utf8)
}
catch let error as NSError {
NSApplication.shared().presentError(error)
NSApplication.shared.presentError(error)
}
}
}
@ -271,7 +270,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")!
let urlString = "mailto:support@ranchero.com?subject=I%20need%20help%20with%20\(escapedAppName)%20\(version)&body=I%20ran%20into%20a%20problem:%20"
if let url = URL(string: urlString) {
NSWorkspace.shared().open(url)
NSWorkspace.shared.open(url)
}
}
@ -321,17 +320,17 @@ private extension AppDelegate {
return
}
let parserData = ParserData(data: opmlData, urlString: url.absoluteString)
RSParseOPML(xmlData) { (opmlDocument, error) in
let parserData = ParserData(url: url.absoluteString, data: opmlData)
RSParseOPML(parserData) { (opmlDocument, error) in
if let error = error {
NSApplication.shared().presentError(error)
NSApplication.shared.presentError(error)
return
}
if let opmlDocument = opmlDocument {
account.importOPML(opmlDocument)
// account.refreshAll()
account.refreshAll()
}
}
}

View File

@ -40,7 +40,7 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
@objc func appNavigationKeyPressed(_ note: Notification) {
guard let key = note.userInfo?[appNavigationKey] as? Int else {
guard let key = note.userInfo?[AppKey.appNavigation] as? Int else {
return
}
guard let contentView = window?.contentView, let view = note.object as? NSView, view.isDescendant(of: contentView) else {

View File

@ -177,9 +177,9 @@ private extension SidebarViewController {
var userInfo = [AnyHashable: Any]()
if let selectedObjects = selectedObjects {
userInfo[objectsKey] = selectedObjects
userInfo[AppKey.objects] = selectedObjects
}
userInfo[viewKey] = self.outlineView
userInfo[AppKey.view] = self.outlineView
NotificationCenter.default.post(name: .SidebarSelectionDidChange, object: self, userInfo: userInfo)
}

View File

@ -56,10 +56,10 @@ final class StatusBarView: NSView {
@objc dynamic func timelineSelectionDidChange(_ note: Notification) {
let timelineView = note.userInfo?[viewKey] as! NSView
let timelineView = note.userInfo?[AppKey.view] as! NSView
if timelineView.window! === self.window {
article = note.userInfo?[articleKey] as? Article
article = note.userInfo?[AppKey.article] as? Article
}
}

View File

@ -414,7 +414,7 @@ class TimelineViewController: NSViewController, NSTableViewDelegate, NSTableView
var fetchedArticles = [Article]()
for (accountID, objects) in accountsDictionary {
guard let oneAccount = account(with: accountID) else {
guard let oneAccount = accountWithID(accountID) else {
continue
}
@ -473,14 +473,14 @@ class TimelineViewController: NSViewController, NSTableViewDelegate, NSTableView
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
let rowView: TimelineTableRowView = tableView.make(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "timelineRow"), owner: self) as! TimelineTableRowView
let rowView: TimelineTableRowView = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "timelineRow"), owner: self) as! TimelineTableRowView
rowView.cellAppearance = cellAppearance
return rowView
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let cell: TimelineTableCellView = tableView.make(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "timelineCell"), owner: self) as! TimelineTableCellView
let cell: TimelineTableCellView = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "timelineCell"), owner: self) as! TimelineTableCellView
cell.cellAppearance = cellAppearance
if let article = articleAtRow(row) {

View File

@ -8,15 +8,18 @@
import Foundation
let SidebarFontSizeKey = "sidebarFontSize"
let TimelineFontSizeKey = "timelineFontSize"
let ArticleFontSizeKey = "articleFontSize"
final class AppDefaults {
}
let SidebarFontSizeKVOKey = "values." + SidebarFontSizeKey
let TimelineFontSizeKVOKey = "values." + TimelineFontSizeKey
let ArticleFontSizeKVOKey = "values." + ArticleFontSizeKey
let OpenInBrowserInBackgroundKey = "openInBrowserInBackground"
extension AppDefaultsKey {
static let sidebarFontSizeKVO = "values." + sidebarFontSize
static let timelineFontSizeKVO = "values." + timelineFontSize
static let detailFontSizeKVO = "values." + detailFontSize
}
enum FontSize: Int {
case small = 0
@ -30,7 +33,7 @@ private let largestFontSizeRawValue = FontSize.veryLarge.rawValue
func registerDefaults() {
let defaults = [SidebarFontSizeKey: FontSize.medium.rawValue, TimelineFontSizeKey: FontSize.medium.rawValue, ArticleFontSizeKey: FontSize.medium.rawValue]
let defaults = [AppDefaultsKey.sidebarFontSize: FontSize.medium.rawValue, AppDefaultsKey.timelineFontSize: FontSize.medium.rawValue, AppDefaultsKey.detailFontSize, FontSize.medium.rawValue]
UserDefaults.standard.register(defaults: defaults)
}

View File

@ -10,9 +10,16 @@ import Cocoa
private struct PreferencesToolbarItemSpec {
let identifier: String // Toolbar item identifier and view controller identifier in storyboard
let identifier: NSToolbarItem.Identifier
let name: String
let imageName: String
let imageName: NSImage.Name
init(identifierRawValue: String, name: String, imageName: NSImage.Name) {
self.identifier = NSToolbarItem.Identifier(rawValue: identifierRawValue)
self.name = name
self.imageName = imageName
}
}
private let toolbarItemIdentifierGeneral = "General"
@ -23,14 +30,14 @@ class PreferencesWindowController : NSWindowController, NSToolbarDelegate {
fileprivate var viewControllers = [String: NSViewController]()
fileprivate let toolbarItemSpecs: [PreferencesToolbarItemSpec] = {
var specs = [PreferencesToolbarItemSpec]()
specs += [PreferencesToolbarItemSpec(identifier: toolbarItemIdentifierGeneral, name: NSLocalizedString("General", comment: "Preferences"), imageName: NSImageNamePreferencesGeneral)]
specs += [PreferencesToolbarItemSpec(identifierRawValue: toolbarItemIdentifierGeneral, name: NSLocalizedString("General", comment: "Preferences"), imageName: NSImage.Name.preferencesGeneral)]
return specs
}()
override func windowDidLoad() {
let toolbar = NSToolbar(identifier: "PreferencesToolbar")
let toolbar = NSToolbar(identifier: NSToolbar.Identifier(rawValue: "PreferencesToolbar"))
toolbar.delegate = self
toolbar.autosavesConfiguration = false
toolbar.allowsUserCustomization = false
@ -70,18 +77,18 @@ class PreferencesWindowController : NSWindowController, NSToolbarDelegate {
return toolbarItem
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return toolbarItemSpecs.map { $0.identifier }
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return toolbarDefaultItemIdentifiers(toolbar)
}
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return toolbarDefaultItemIdentifiers(toolbar)
}
}

View File

@ -89,6 +89,11 @@ public final class Account: DisplayNameProvider, Hashable {
return nil //TODO
}
public func importOPML(_ opmlDocument: RSOPMLDocument) {
// TODO
}
// MARK: - Equatable
public class func ==(lhs: Account, rhs: Account) -> Bool {

View File

@ -98,7 +98,7 @@ public final class AccountManager: UnreadCountProvider {
return false
}
func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
public func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
for account in accounts {
if let _ = account.existingFeed(withURL: urlString) {
@ -191,7 +191,7 @@ private func accountFilePathWithFolder(_ folderPath: String) -> String {
return NSString(string: folderPath).appendingPathComponent(accountDataFileName)
}
public func account(with accountID: String) -> Account? {
public func accountWithID(_ accountID: String) -> Account? {
// Shortcut.
return AccountManager.sharedInstance.existingAccount(with: accountID)

View File

@ -13,7 +13,7 @@ public extension Article {
var account: Account? {
get {
return account(with: accountID)
return accountWithID(accountID)
}
}
}

View File

@ -13,7 +13,7 @@ public extension Feed {
var account: Account? {
get {
return account(with: accountID)
return accountWithID(accountID)
}
}
}

View File

@ -16,7 +16,7 @@ public final class Folder: DisplayNameProvider, UnreadCountProvider {
public var account: Account? {
get {
return account(with: accountID)
return accountWithID(accountID)
}
}

View File

@ -56,12 +56,17 @@ public struct Article: Hashable {
self.articleID = articleID
}
else {
self.articleID = databaseIDWithString("\(feedID) \(uniqueID)")
self.articleID = Article.calculatedArticleID(feedID: feedID, uniqueID: uniqueID)
}
self.hashValue = accountID.hashValue ^ self.articleID.hashValue
}
public static func calculatedArticleID(feedID: String, uniqueID: String) -> String {
return databaseIDWithString("\(feedID) \(uniqueID)")
}
public static func ==(lhs: Article, rhs: Article) -> Bool {
return lhs.hashValue == rhs.hashValue && lhs.articleID == rhs.articleID && lhs.accountID == rhs.accountID && lhs.feedID == rhs.feedID && lhs.uniqueID == rhs.uniqueID && lhs.title == rhs.title && lhs.contentHTML == rhs.contentHTML && lhs.url == rhs.url && lhs.externalURL == rhs.externalURL && lhs.summary == rhs.summary && lhs.imageURL == rhs.imageURL && lhs.bannerImageURL == rhs.bannerImageURL && lhs.datePublished == rhs.datePublished && lhs.authors == rhs.authors && lhs.tags == rhs.tags && lhs.attachments == rhs.attachments

View File

@ -16,7 +16,7 @@ import RSCore
private var databaseIDCache = [String: String]()
private var databaseIDCacheLock = os_unfair_lock_s()
func databaseIDWithString(_ s: String) -> String {
public func databaseIDWithString(_ s: String) -> String {
os_unfair_lock_lock(&databaseIDCacheLock)
defer {

View File

@ -85,8 +85,8 @@ final class ArticlesTable: DatabaseTable {
return
}
// 1. Create incoming articles with parsedItems.
// 2. Ensure statuses for all the incoming articles.
// 1. Ensure statuses for all the incoming articles.
// 2. Create incoming articles with parsedItems.
// 3. Ignore incoming articles that are userDeleted || (!starred and really old)
// 4. Fetch all articles for the feed.
// 5. Create array of Articles not in database and save them.
@ -94,19 +94,32 @@ final class ArticlesTable: DatabaseTable {
// 7. Call back with new and updated Articles.
let feedID = feed.feedID
let articleIDs = Set(parsedFeed.items.map { $0.articleID })
self.queue.run { (database) in
self.queue.update { (database) in
// This doesnt hit the database, but it should be done on the database queue.
let allIncomingArticles = Article.articlesWithParsedItems(parsedFeed.items, self.accountID, feedID) //1
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, database) //1
assert(statusesDictionary.count == articleIDs.count)
let allIncomingArticles = Article.articlesWithParsedItems(parsedFeed.items, self.accountID, feedID, statusesDictionary) //2
if allIncomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
DispatchQueue.main.async {
self.ensureStatusesAndSaveArticles(allIncomingArticles, feedID, completion) //2-7
let incomingArticles = self.filterIncomingArticles(allIncomingArticles, statusesDictionary) //3
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database: database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
}
}
@ -149,9 +162,7 @@ private extension ArticlesTable {
// Then fetch the related objects, given the set of articleIDs.
// Then create set of Articles *with* related objects and return it.
let (stubArticles, statuses) = stubArticlesAndStatuses(with: resultSet)
statusesTable.addIfNotCached(statuses)
let stubArticles = makeStubArticles(with: resultSet)
if stubArticles.isEmpty {
return stubArticles
}
@ -173,25 +184,25 @@ private extension ArticlesTable {
return articles
}
func stubArticlesAndStatuses(with resultSet: FMResultSet) -> (Set<Article>, Set<ArticleStatus>) {
func makeStubArticles(with resultSet: FMResultSet) -> Set<Article> {
var stubArticles = Set<Article>()
var statuses = Set<ArticleStatus>()
// Note: the resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups.
while resultSet.next() {
if let stubArticle = Article(row: resultSet, accountID: accountID) {
stubArticles.insert(stubArticle)
guard let status = statusesTable.statusWithRow(resultSet) else {
assertionFailure("Expected status.")
continue
}
if let status = statusesTable.statusWithRow(resultSet) {
statuses.insert(status)
if let stubArticle = Article(row: resultSet, accountID: accountID, status: status) {
stubArticles.insert(stubArticle)
}
}
resultSet.close()
return (stubArticles, statuses)
return stubArticles
}
func articleWithAttachedRelatedObjects(_ stubArticle: Article, _ authorsMap: RelatedObjectsMap?, _ attachmentsMap: RelatedObjectsMap?, _ tagsMap: RelatedObjectsMap?) -> Article {
@ -263,32 +274,6 @@ private extension ArticlesTable {
// MARK: Saving Parsed Items
private func ensureStatusesAndSaveArticles(_ allIncomingArticles: Set<Article>, _ feedID: String, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
statusesTable.ensureStatusesForArticleIDs(allIncomingArticles.articleIDs()) { (statusesDictionary) in // 2
self.queue.update{ (database) in
self.saveArticlesWithDatabase(allIncomingArticles, statusesDictionary, feedID, database, completion)
}
}
}
private func saveArticlesWithDatabase(_ allIncomingArticles: Set<Article>, _ statusesDictionary: [String: ArticleStatus], _ feedID: String, _ database: FMDatabase, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) { // 3-7
let incomingArticles = filterIncomingArticles(allIncomingArticles, statusesDictionary) //3
if incomingArticles.isEmpty {
callUpdateArticlesCompletionBlock(nil, nil, completion)
return
}
let fetchedArticles = fetchArticlesForFeedID(feedID, withLimits: false, database: database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion)
}
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {

View File

@ -10,6 +10,7 @@
840405CF1F1A963700DF0296 /* AttachmentsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840405CE1F1A963700DF0296 /* AttachmentsTable.swift */; };
84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */; };
84288A021F6A3D8000395871 /* RelatedObjectsMap+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */; };
843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */; };
843CB9961F34174100EE6581 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20F901F1810DD00D8E682 /* Author+Database.swift */; };
844BEE411F0AB3AB004AB7CD /* Database.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 844BEE371F0AB3AA004AB7CD /* Database.framework */; };
844BEE461F0AB3AB004AB7CD /* DatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844BEE451F0AB3AB004AB7CD /* DatabaseTests.swift */; };
@ -116,6 +117,7 @@
840405CE1F1A963700DF0296 /* AttachmentsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsTable.swift; sourceTree = "<group>"; };
842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseObject+Database.swift"; sourceTree = "<group>"; };
84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelatedObjectsMap+Database.swift"; sourceTree = "<group>"; };
843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "ParsedArticle+Database.swift"; path = "Extensions/ParsedArticle+Database.swift"; sourceTree = "<group>"; };
844BEE371F0AB3AA004AB7CD /* Database.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Database.framework; sourceTree = BUILT_PRODUCTS_DIR; };
844BEE401F0AB3AB004AB7CD /* DatabaseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DatabaseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
844BEE451F0AB3AB004AB7CD /* DatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTests.swift; sourceTree = "<group>"; };
@ -216,6 +218,7 @@
children = (
846FB36A1F4A937B00EAB81D /* Feed+Database.swift */,
845580751F0AF670003CCFA1 /* Article+Database.swift */,
843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */,
845580791F0AF67D003CCFA1 /* ArticleStatus+Database.swift */,
84F20F901F1810DD00D8E682 /* Author+Database.swift */,
8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */,
@ -483,6 +486,7 @@
840405CF1F1A963700DF0296 /* AttachmentsTable.swift in Sources */,
84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */,
84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */,
843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */,
84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */,
84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */,
84E156EA1F0AB80500F8CC05 /* Database.swift in Sources */,

View File

@ -13,7 +13,7 @@ import RSParser
extension Article {
init?(row: FMResultSet, accountID: String, authors: Set<Author>? = nil, attachments: Set<Attachment>? = nil, tags: Set<String>? = nil) {
init?(row: FMResultSet, accountID: String, authors: Set<Author>? = nil, attachments: Set<Attachment>? = nil, tags: Set<String>? = nil, status: ArticleStatus) {
guard let feedID = row.string(forColumn: DatabaseKey.feedID) else {
return nil
@ -35,15 +35,15 @@ extension Article {
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
let accountInfo: AccountInfo? = nil // TODO
self.init(accountID: accountID, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: accountInfo)
self.init(accountID: accountID, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: accountInfo, status: status)
}
init(parsedItem: ParsedItem, accountID: String, feedID: String) {
init(parsedItem: ParsedItem, accountID: String, feedID: String, status: ArticleStatus) {
let authors = Author.authorsWithParsedAuthors(parsedItem.authors)
let attachments = Attachment.attachmentsWithParsedAttachments(parsedItem.attachments)
self.init(accountID: accountID, articleID: parsedItem.syncServiceID, feedID: feedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, bannerImageURL: parsedItem.bannerImageURL, datePublished: parsedItem.datePublished, dateModified: parsedItem.dateModified, authors: authors, tags: parsedItem.tags, attachments: attachments, accountInfo: nil)
self.init(accountID: accountID, articleID: parsedItem.syncServiceID, feedID: feedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, bannerImageURL: parsedItem.bannerImageURL, datePublished: parsedItem.datePublished, dateModified: parsedItem.dateModified, authors: authors, tags: parsedItem.tags, attachments: attachments, accountInfo: nil, status: status)
}
func articleByAttaching(_ authors: Set<Author>?, _ attachments: Set<Attachment>?, _ tags: Set<String>?) -> Article {
@ -52,7 +52,7 @@ extension Article {
return self
}
return Article(accountID: accountID, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: accountInfo)
return Article(accountID: accountID, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: accountInfo, status: status)
}
private func addPossibleStringChangeWithKeyPath(_ comparisonKeyPath: KeyPath<Article,String?>, _ otherArticle: Article, _ key: String, _ dictionary: NSMutableDictionary) {
@ -108,9 +108,9 @@ extension Article {
return d
}
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ accountID: String, _ feedID: String) -> Set<Article> {
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ accountID: String, _ feedID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
return Set(parsedItems.map{ Article(parsedItem: $0, accountID: accountID, feedID: feedID) })
return Set(parsedItems.map{ Article(parsedItem: $0, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
}
}

View File

@ -0,0 +1,24 @@
//
// ParsedArticle+Database.swift
// Database
//
// Created by Brent Simmons on 9/18/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import RSParser
import Data
extension ParsedItem {
var articleID: String {
get {
if let s = syncServiceID {
return s
}
// Must be same calculation as for Article.
return Article.calculatedArticleID(feedID: feedURL, uniqueID: uniqueID)
}
}
}

View File

@ -15,8 +15,6 @@ import Data
//
// CREATE TABLE if not EXISTS statuses (articleID TEXT NOT NULL PRIMARY KEY, read BOOL NOT NULL DEFAULT 0, starred BOOL NOT NULL DEFAULT 0, userDeleted BOOL NOT NULL DEFAULT 0, dateArrived DATE NOT NULL DEFAULT 0, accountInfo BLOB);
typealias StatusesCompletionBlock = ([String: ArticleStatus]) -> Void // [articleID: Status]
final class StatusesTable: DatabaseTable {
let name = DatabaseTableName.statuses
@ -28,56 +26,26 @@ final class StatusesTable: DatabaseTable {
self.queue = queue
}
// MARK: Cache
// func cachedStatus(for articleID: String) -> ArticleStatus? {
//
// assert(Thread.isMainThread)
// assert(cache[articleID] != nil)
// return cache[articleID]
// }
//
// func cachedStatuses(for articleIDs: Set<String>) -> Set<ArticleStatus> {
//
// assert(Thread.isMainThread)
//
// var statuses = Set<ArticleStatus>()
// for articleID in articleIDs {
// if let articleStatus = cache[articleID] {
// statuses.insert(articleStatus)
// }
// }
//
// return statuses
// }
// MARK: Creating/Updating
func ensureStatusesForArticleIDs(_ articleIDs: Set<String>, _ completion: @escaping StatusesCompletionBlock) {
// Adds them to the cache if not cached.
assert(Thread.isMainThread)
func ensureStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> [String: ArticleStatus] {
// Check cache.
let articleIDsMissingCachedStatus = articleIDsWithNoCachedStatus(articleIDs)
if articleIDsMissingCachedStatus.isEmpty {
completion(statusesDictionary(articleIDs))
return
return statusesDictionary(articleIDs)
}
// Check database.
fetchAndCacheStatusesForArticleIDs(articleIDsMissingCachedStatus) {
fetchAndCacheStatusesForArticleIDs(articleIDsMissingCachedStatus, database)
let articleIDsNeedingStatus = self.articleIDsWithNoCachedStatus(articleIDs)
if !articleIDsNeedingStatus.isEmpty {
// Create new statuses.
self.createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus)
}
completion(self.statusesDictionary(articleIDs))
let articleIDsNeedingStatus = self.articleIDsWithNoCachedStatus(articleIDs)
if !articleIDsNeedingStatus.isEmpty {
// Create new statuses.
self.createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, database)
}
return statusesDictionary(articleIDs)
}
// MARK: Marking
@ -100,9 +68,10 @@ final class StatusesTable: DatabaseTable {
if updatedStatuses.isEmpty {
return
}
let articleIDs = updatedStatuses.articleIDs()
queue.update { (database) in
self.markArticleIDs(updatedStatuses.articleIDs(), statusKey, flag, database)
self.markArticleIDs(articleIDs, statusKey, flag, database)
}
}
@ -113,11 +82,17 @@ final class StatusesTable: DatabaseTable {
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
return nil
}
if let cachedStatus = cache[articleID] {
return cachedStatus
}
guard let dateArrived = row.date(forColumn: DatabaseKey.dateArrived) else {
return nil
}
let articleStatus = ArticleStatus(articleID: articleID, dateArrived: dateArrived, row: row)
cache.addStatusIfNotCached(articleStatus)
return articleStatus
}
}
@ -130,13 +105,13 @@ private extension StatusesTable {
func articleIDsWithNoCachedStatus(_ articleIDs: Set<String>) -> Set<String> {
assert(Thread.isMainThread)
assert(!Thread.isMainThread)
return Set(articleIDs.filter { cache[$0] == nil })
}
func statusesDictionary(_ articleIDs: Set<String>) -> [String: ArticleStatus] {
assert(Thread.isMainThread)
assert(!Thread.isMainThread)
var d = [String: ArticleStatus]()
@ -149,75 +124,31 @@ private extension StatusesTable {
return d
}
func addToCache(_ statuses: Set<ArticleStatus>) {
// Replacing any already cached statuses.
if statuses.isEmpty {
return
}
if Thread.isMainThread {
self.cache.add(statuses)
}
else {
DispatchQueue.main.async {
self.cache.add(statuses)
}
}
}
func addIfNotCached(_ statuses: Set<ArticleStatus>) {
if statuses.isEmpty {
return
}
if Thread.isMainThread {
self.cache.addIfNotCached(statuses)
}
else {
DispatchQueue.main.async {
self.cache.addIfNotCached(statuses)
}
}
}
// MARK: Creating
func saveStatuses(_ statuses: Set<ArticleStatus>) {
func saveStatuses(_ statuses: Set<ArticleStatus>, _ database: FMDatabase) {
queue.update { (database) in
let statusArray = statuses.map { $0.databaseDictionary()! }
self.insertRows(statusArray, insertType: .orIgnore, in: database)
}
let statusArray = statuses.map { $0.databaseDictionary()! }
self.insertRows(statusArray, insertType: .orIgnore, in: database)
}
func createAndSaveStatusesForArticleIDs(_ articleIDs: Set<String>) {
func createAndSaveStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) {
assert(Thread.isMainThread)
let now = Date()
let statuses = Set(articleIDs.map { ArticleStatus(articleID: $0, dateArrived: now) })
cache.addIfNotCached(statuses)
saveStatuses(statuses)
saveStatuses(statuses, database)
}
func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set<String>, _ completion: @escaping RSVoidCompletionBlock) {
func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) {
queue.fetch { (database) in
guard let resultSet = self.selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) else {
completion()
return
}
let statuses = resultSet.mapToSet(self.statusWithRow)
DispatchQueue.main.async {
self.cache.addIfNotCached(statuses)
completion()
}
guard let resultSet = self.selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) else {
return
}
let statuses = resultSet.mapToSet(self.statusWithRow)
self.cache.addIfNotCached(statuses)
}
// MARK: Marking
@ -228,6 +159,8 @@ private extension StatusesTable {
}
}
// MARK: -
private final class StatusCache {
// Serial database queue only.
@ -245,6 +178,11 @@ private final class StatusCache {
}
}
func addStatusIfNotCached(_ status: ArticleStatus) {
addIfNotCached(Set([status]))
}
func addIfNotCached(_ statuses: Set<ArticleStatus>) {
// Does not replace already cached statuses.

View File

@ -12,6 +12,12 @@
@class ParserData;
@class RSOPMLDocument;
typedef void (^OPMLParserCallback)(RSOPMLDocument *opmlDocument, NSError *error);
// Parses on background thread; calls back on main thread.
void RSParseOPML(ParserData *parserData, OPMLParserCallback callback);
@interface RSOPMLParser: NSObject
+ (RSOPMLDocument *)parseOPMLWithParserData:(ParserData *)parserData error:(NSError **)error;

View File

@ -24,6 +24,20 @@
@end
void RSParseOPML(ParserData *parserData, OPMLParserCallback callback) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@autoreleasepool {
NSError *error = nil;
RSOPMLDocument *opmlDocument = [RSOPMLParser parseOPMLWithParserData:parserData error:&error];
dispatch_async(dispatch_get_main_queue(), ^{
callback(opmlDocument, error);
});
}
});
}
@implementation RSOPMLParser

View File

@ -6,12 +6,12 @@
</editor> -->
<title>ToDo</title>
<dateCreated>Tue, 12 Sep 2017 20:15:17 GMT</dateCreated>
<expansionState>0,18,21,25,30,40,41,43,47,50,52,54,56,65,70</expansionState>
<expansionState>0,23,26,30,35,45,46,48,52,55,57,59,61,70,75</expansionState>
<vertScrollState>0</vertScrollState>
<windowTop>452</windowTop>
<windowLeft>543</windowLeft>
<windowRight>1275</windowRight>
<windowBottom>1211</windowBottom>
<windowTop>207</windowTop>
<windowLeft>30</windowLeft>
<windowRight>762</windowRight>
<windowBottom>966</windowBottom>
</head>
<body>
<outline text="App">
@ -26,10 +26,17 @@
<outline text="NSError.setUserInfoValueProvider"/>
<outline text="NSDictionary.sharedKeySet"/>
<outline text="Periodic refresh"/>
<outline text="OPML Import">
<outline text="Accessory view in choose-file sheet for choosing account"/>
<outline text="Import from URL">
<outline text="Sheet with URL text field and account popup"/>
<outline text="Download URL"/>
<outline text="Progress while downloading URL"/>
<outline text="Report error if needed"/>
</outline>
</outline>
<outline text="Commands">
<outline text="Star"/>
<outline text="Import OPML from File"/>
<outline text="Import OPML from URL"/>
<outline text="Toggle sidebar"/>
</outline>
<outline text="Main window">