Add LocalAccount framework. Note: build is broken.

This commit is contained in:
Brent Simmons 2017-05-23 13:24:42 -07:00
parent 91d81831e9
commit e4d1ed8bd9
17 changed files with 3027 additions and 1 deletions

View File

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
8471A2C41ED4CEBF008F099E /* DataModel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2B71ED4CEAD008F099E /* DataModel.framework */; };
8471A2C51ED4CEBF008F099E /* DataModel.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2B71ED4CEAD008F099E /* DataModel.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
8471A2F51ED4D062008F099E /* LocalAccount.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2F21ED4D04D008F099E /* LocalAccount.framework */; };
8471A2F61ED4D062008F099E /* LocalAccount.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2F21ED4D04D008F099E /* LocalAccount.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
849C64641ED37A5D003D8FC0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849C64631ED37A5D003D8FC0 /* AppDelegate.swift */; };
849C64661ED37A5D003D8FC0 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849C64651ED37A5D003D8FC0 /* ViewController.swift */; };
849C64681ED37A5D003D8FC0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
@ -47,6 +49,27 @@
remoteGlobalIDString = 84C7AE911D68C558009FB883;
remoteInfo = DataModel;
};
8471A2F11ED4D04D008F099E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */;
proxyType = 2;
remoteGlobalIDString = 84C7AEA71D68C79A009FB883;
remoteInfo = LocalAccount;
};
8471A2F31ED4D04D008F099E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */;
proxyType = 2;
remoteGlobalIDString = 84C7AEB01D68C79A009FB883;
remoteInfo = LocalAccountTests;
};
8471A2F71ED4D062008F099E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */;
proxyType = 1;
remoteGlobalIDString = 84C7AEA61D68C79A009FB883;
remoteInfo = LocalAccount;
};
849C64721ED37A5D003D8FC0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 849C64581ED37A5D003D8FC0 /* Project object */;
@ -265,6 +288,7 @@
84B06FFE1ED3818D00F0B54B /* RSTree.framework in Embed Frameworks */,
84B06FAF1ED37DBD00F0B54B /* RSCore.framework in Embed Frameworks */,
84B06FD01ED37F7D00F0B54B /* DB5.framework in Embed Frameworks */,
8471A2F61ED4D062008F099E /* LocalAccount.framework in Embed Frameworks */,
8471A2C51ED4CEBF008F099E /* DataModel.framework in Embed Frameworks */,
84B06FC31ED37E9600F0B54B /* RSWeb.framework in Embed Frameworks */,
84B06F831ED37BDD00F0B54B /* RSXML.framework in Embed Frameworks */,
@ -276,6 +300,7 @@
/* Begin PBXFileReference section */
8471A2B21ED4CEAD008F099E /* DataModel.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = DataModel.xcodeproj; path = Frameworks/DataModel/DataModel.xcodeproj; sourceTree = "<group>"; };
8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = LocalAccount.xcodeproj; path = Frameworks/LocalAccount/LocalAccount.xcodeproj; sourceTree = "<group>"; };
849C64601ED37A5D003D8FC0 /* Evergreen.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Evergreen.app; sourceTree = BUILT_PRODUCTS_DIR; };
849C64631ED37A5D003D8FC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Evergreen/AppDelegate.swift; sourceTree = "<group>"; };
849C64651ED37A5D003D8FC0 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewController.swift; path = Evergreen/ViewController.swift; sourceTree = "<group>"; };
@ -306,6 +331,7 @@
84B06FFD1ED3818D00F0B54B /* RSTree.framework in Frameworks */,
84B06FAE1ED37DBD00F0B54B /* RSCore.framework in Frameworks */,
84B06FCF1ED37F7D00F0B54B /* DB5.framework in Frameworks */,
8471A2F51ED4D062008F099E /* LocalAccount.framework in Frameworks */,
8471A2C41ED4CEBF008F099E /* DataModel.framework in Frameworks */,
84B06FC21ED37E9600F0B54B /* RSWeb.framework in Frameworks */,
84B06F821ED37BDD00F0B54B /* RSXML.framework in Frameworks */,
@ -330,6 +356,15 @@
name = Products;
sourceTree = "<group>";
};
8471A2ED1ED4D04D008F099E /* Products */ = {
isa = PBXGroup;
children = (
8471A2F21ED4D04D008F099E /* LocalAccount.framework */,
8471A2F41ED4D04D008F099E /* LocalAccountTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
849C64571ED37A5D003D8FC0 = {
isa = PBXGroup;
children = (
@ -340,8 +375,9 @@
849C646C1ED37A5D003D8FC0 /* Info.plist */,
849C64741ED37A5D003D8FC0 /* EvergreenTests */,
849C64611ED37A5D003D8FC0 /* Products */,
8471A2B21ED4CEAD008F099E /* DataModel.xcodeproj */,
84B06FC61ED37F7200F0B54B /* DB5.xcodeproj */,
8471A2B21ED4CEAD008F099E /* DataModel.xcodeproj */,
8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */,
84B06FA21ED37DAC00F0B54B /* RSCore.xcodeproj */,
84B06F961ED37DA000F0B54B /* RSDatabase.xcodeproj */,
84B06FE01ED3803200F0B54B /* RSFeedFinder.xcodeproj */,
@ -470,6 +506,7 @@
84B070001ED3818D00F0B54B /* PBXTargetDependency */,
84B0700D1ED3822600F0B54B /* PBXTargetDependency */,
8471A2C71ED4CEBF008F099E /* PBXTargetDependency */,
8471A2F81ED4D062008F099E /* PBXTargetDependency */,
);
name = Evergreen;
productName = Evergreen;
@ -537,6 +574,10 @@
ProductGroup = 84B06FC71ED37F7200F0B54B /* Products */;
ProjectRef = 84B06FC61ED37F7200F0B54B /* DB5.xcodeproj */;
},
{
ProductGroup = 8471A2ED1ED4D04D008F099E /* Products */;
ProjectRef = 8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */;
},
{
ProductGroup = 84B06FA31ED37DAC00F0B54B /* Products */;
ProjectRef = 84B06FA21ED37DAC00F0B54B /* RSCore.xcodeproj */;
@ -582,6 +623,20 @@
remoteRef = 8471A2B61ED4CEAD008F099E /* PBXContainerItemProxy */;
sourceTree = BUILT_PRODUCTS_DIR;
};
8471A2F21ED4D04D008F099E /* LocalAccount.framework */ = {
isa = PBXReferenceProxy;
fileType = wrapper.framework;
path = LocalAccount.framework;
remoteRef = 8471A2F11ED4D04D008F099E /* PBXContainerItemProxy */;
sourceTree = BUILT_PRODUCTS_DIR;
};
8471A2F41ED4D04D008F099E /* LocalAccountTests.xctest */ = {
isa = PBXReferenceProxy;
fileType = wrapper.cfbundle;
path = LocalAccountTests.xctest;
remoteRef = 8471A2F31ED4D04D008F099E /* PBXContainerItemProxy */;
sourceTree = BUILT_PRODUCTS_DIR;
};
84B06F7D1ED37BCA00F0B54B /* RSXML.framework */ = {
isa = PBXReferenceProxy;
fileType = wrapper.framework;
@ -769,6 +824,11 @@
name = DataModel;
targetProxy = 8471A2C61ED4CEBF008F099E /* PBXContainerItemProxy */;
};
8471A2F81ED4D062008F099E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = LocalAccount;
targetProxy = 8471A2F71ED4D062008F099E /* PBXContainerItemProxy */;
};
849C64731ED37A5D003D8FC0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 849C645F1ED37A5D003D8FC0 /* Evergreen */;

View File

@ -0,0 +1,15 @@
//
// DiskDictionaryConstants.swift
// Rainier
//
// Created by Brent Simmons on 4/9/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
let diskDictionarySettingsKey = "settings"
let diskDictionaryChildrenKey = "children"
let diskDictionaryFolderIDKey = "folderID"
let diskDictionaryFeedIDKey = "feedID"

View File

@ -0,0 +1,26 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2016 Ranchero Software, LLC. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>
</plist>

View File

@ -0,0 +1,654 @@
//
// LocalAccount.swift
// Rainier
//
// Created by Brent Simmons on 4/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSXML
import RSWeb
import DataModel
private let localAccountType = "OnMyMac"
public final class LocalAccount: Account, PlistProvider {
public let identifier: String
public let type = localAccountType
public let nameForDisplay = NSLocalizedString("On My Mac", comment: "Local account name")
private let settingsFile: String
private let dataFolder: String
private let diskSaver: DiskSaver
fileprivate let topLevelFolders = NSMutableDictionary()
fileprivate let topLevelFeeds = NSMutableDictionary()
fileprivate let localDatabase: LocalDatabase
private let refresher = LocalAccountRefresher()
public var flattenedFeeds: NSSet {
get {
let feeds = NSMutableSet(array: topLevelFeeds.allValues)
for oneFolder in topLevelFolders.allValues {
feeds.addObjects(from: (oneFolder as! LocalFolder).flattenedFeeds.allObjects)
}
return feeds
}
}
public var flattenedFeedIDs: Set<String> {
get {
return Set(flattenedFeeds.flatMap { ($0 as? LocalFeed)?.feedID })
}
}
public var account: Account? {
get {
return self
}
}
public var unreadCount = 0 {
didSet {
postUnreadCountDidChangeNotification()
}
}
public var plist: AnyObject? {
get {
return createDiskDictionary()
}
}
public var refreshInProgress: Bool {
get {
return !refresher.progress.isComplete
}
}
required public init(settingsFile: String, dataFolder: String, identifier: String) {
self.settingsFile = settingsFile
self.dataFolder = dataFolder
self.identifier = identifier
let databaseFile = (dataFolder as NSString).appendingPathComponent("Articles0.db")
self.localDatabase = LocalDatabase(databaseFile: databaseFile)
self.diskSaver = DiskSaver(path: settingsFile)
self.localDatabase.account = self
self.diskSaver.delegate = self
self.refresher.account = self
pullSettingsAndTopLevelItemsFromFile()
self.localDatabase.startup()
updateUnreadCountsForTopLevelFolders()
updateUnreadCount()
NotificationCenter.default.addObserver(self, selector: #selector(folderChildrenDidChange(_:)), name: NSNotification.Name(rawValue: FolderChildrenDidChangeNotification), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(articleStatusesDidChange(_:)), name: .ArticleStatusesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(refreshProgressDidChange(_:)), name: .RSProgressDidChange, object: nil)
DispatchQueue.main.async() { () -> Void in
self.updateUnreadCounts(feedIDs: self.flattenedFeedIDs)
}
}
public init?(plist: AnyObject) {
return nil
}
// MARK: Account
public func refreshAll() {
refresher.refreshFeeds(flattenedFeeds)
}
public func markArticles(_ articles: NSSet, statusKey: ArticleStatusKey, flag: Bool) {
if statusKey == .read {
for oneArticle in articles {
if let oneArticle = oneArticle as? LocalArticle, let oneFeed = existingFeedWithID(oneArticle.feedID) as? LocalFeed {
oneFeed.addToUnreadCount(amount: flag ? -1 : 1)
}
}
}
localDatabase.markArticles(articles, statusKey: statusKey, flag: flag)
postArticleStatusesDidChangeNotification(articles)
}
public func importOPML(_ opmlDocument: Any) {
if let opmlItems = (opmlDocument as? RSOPMLDocument)?.children as? [RSOPMLItem] {
performDataModelBatchUpdates {
importOPMLItems(opmlItems)
}
}
refreshAll()
}
public func fetchArticles(for objects: [AnyObject]) -> [Article] {
var articlesSet = Set<LocalArticle>()
for oneObject in objects {
if let oneFeed = oneObject as? LocalFeed {
articlesSet.formUnion(fetchArticlesForFeed(oneFeed))
}
else if let oneFolder = oneObject as? LocalFolder {
articlesSet.formUnion(fetchArticlesForFolder(oneFolder))
}
}
return Array(articlesSet)
}
// MARK: Folder
public func fetchArticles() -> [Article] {
return [Article]() // Shouldnt get called.
}
public func canAddItem(_ item: AnyObject) -> Bool {
return item is Feed || item is Folder
}
public func addItem(_ item: AnyObject) -> Bool {
if !canAddItem(item) {
return false
}
if let feed = item as? LocalFeed {
return addFeed(feed)
}
if let folder = item as? LocalFolder {
return addFolder(folder)
}
return false
}
public func canAddFolderWithName(_ folderName: String) -> Bool {
return true
}
public func ensureFolderWithName(_ folderName: String) -> Folder? {
if let folder = existingFolderWithName(folderName) {
return folder
}
let folder = LocalFolder(nameForDisplay: folderName, account: self)
if addItem(folder) {
return folder
}
return nil
}
public func createFeedWithName(_ name: String?, editedName: String?, urlString: String) -> Feed? {
let feed = LocalFeed(account: self, url: urlString, feedID: urlString)
feed.name = name
feed.editedName = editedName
return feed
}
public func deleteItems(_ items: [AnyObject]) {
items.forEach { deleteItem($0) }
FolderPostChildrenDidChangeNotification(self)
}
public func existingFeedWithID(_ feedID: String) -> Feed? {
return existingFeedWithURL(feedID)
}
public func existingFeedWithURL(_ urlString: String) -> Feed? {
if let feed = topLevelFeeds[urlString] as? Feed {
return feed
}
for oneFolder in topLevelFolders.allValues {
if let oneFolder = oneFolder as? LocalFolder {
if let feed = oneFolder.existingFeedWithURL(urlString) {
return feed
}
}
}
return nil
}
// MARK: UnreadCountProvider
public func updateUnreadCount() {
var updatedUnreadCount = 0
let _ = visitObjects(false) { (oneChild) -> Bool in
if let oneUnreadCountProvider = oneChild as? UnreadCountProvider {
updatedUnreadCount += oneUnreadCountProvider.unreadCount
}
return false
}
if updatedUnreadCount != unreadCount {
unreadCount = updatedUnreadCount
}
}
func updateUnreadCountForFeed(_ feed: LocalFeed) {
updateUnreadCounts(feedIDs: [feed.feedID])
}
public func visitObjects(_ recurse: Bool, visitBlock: FolderVisitBlock) -> Bool {
for oneFeed in topLevelFeeds.allValues {
if visitBlock(oneFeed as AnyObject) {
return true
}
}
for oneFolder in topLevelFolders.allValues {
if visitBlock(oneFolder as AnyObject) {
return true
}
if recurse {
if let oneFolder = oneFolder as? Folder {
if oneFolder.visitObjects(recurse, visitBlock: visitBlock) {
return true
}
}
}
}
return false
}
// MARK: Notifications
dynamic func folderChildrenDidChange(_ note: Notification) {
if let _ = note.object as? LocalAccount {
diskSaver.dirty = true
}
else if let obj = note.object, objectIsDescendant(obj as AnyObject) {
diskSaver.dirty = true
}
updateUnreadCount()
}
dynamic func articleStatusesDidChange(_ note: Notification) {
guard let articles = note.userInfo?[articlesKey] as? NSSet else {
return
}
var feedIDs = Set<String>()
for oneArticle in articles {
if let oneLocalArticle = oneArticle as? LocalArticle {
feedIDs.insert(oneLocalArticle.feedID)
}
}
if feedIDs.isEmpty {
return
}
updateUnreadCounts(feedIDs: feedIDs)
diskSaver.dirty = true
}
dynamic func unreadCountDidChange(_ notification: Notification) {
guard let obj = notification.object else {
return
}
if obj is LocalFeed || obj is LocalFolder || obj is LocalAccount {
diskSaver.dirty = true
}
updateUnreadCount()
}
dynamic func refreshProgressDidChange(_ notification: Notification) {
guard let progress = notification.object as? RSProgress, progress === refresher.progress else {
return
}
NotificationCenter.default.post(name: .AccountRefreshProgressDidChange, object: self, userInfo: [progressKey: progress])
}
// MARK: Private
private func addFeed(_ feed: LocalFeed) -> Bool {
topLevelFeeds[feed.feedID] = feed
FolderPostChildrenDidChangeNotification(self)
return true
}
private func addFolder(_ folder: LocalFolder) -> Bool {
topLevelFolders[folder.folderID] = folder
FolderPostChildrenDidChangeNotification(self)
return true
}
// MARK: Fetching
func fetchArticlesForFeed(_ feed: LocalFeed) -> Set<LocalArticle> {
return localDatabase.fetchArticlesForFeed(feed)
}
func fetchArticlesForFolder(_ folder: LocalFolder) -> Set<LocalArticle> {
return localDatabase.fetchUnreadArticlesForFolder(folder)
}
// MARK: Updating
func update(_ feed: LocalFeed, parsedFeed: RSParsedFeed, completionHandler: @escaping RSVoidCompletionBlock) {
if let titleFromFeed = parsedFeed.title {
if feed.name != titleFromFeed {
feed.name = titleFromFeed
self.diskSaver.dirty = true
}
}
if let linkFromFeed = parsedFeed.link {
if feed.homePageURL != linkFromFeed {
feed.homePageURL = linkFromFeed
self.diskSaver.dirty = true
}
}
localDatabase.updateFeedWithParsedFeed(feed, parsedFeed: parsedFeed) {
feed.updateUnreadCount()
completionHandler()
}
}
// MARK: Writing to Disk
private func createDiskDictionary() -> NSDictionary {
let d = NSMutableDictionary()
let diskChildren = NSMutableArray()
topLevelFolders.allValues.forEach { (oneFolder) in
if let oneFolder = oneFolder as? PlistProvider, let onePlist = oneFolder.plist {
diskChildren.add(onePlist)
}
}
topLevelFeeds.allValues.forEach { (oneFeed) in
if let oneFeed = oneFeed as? PlistProvider, let onePlist = oneFeed.plist {
diskChildren.add(onePlist)
}
}
d.setObject(diskChildren as NSArray, forKey: diskDictionaryChildrenKey as NSString)
return d
}
// MARK: Reading from Disk
private func pullSettingsAndTopLevelItemsFromFile() {
guard let d = NSDictionary(contentsOfFile: settingsFile) else {
return
}
performDataModelBatchUpdates {
if let children = d[diskDictionaryChildrenKey] as? NSArray {
pullTopLevelItemsFromArray(children)
}
}
}
func objectWithDiskDictionary(_ d: NSDictionary) -> AnyObject? {
if let _ = d[feedURLKey] {
return LocalFeed(account: self, diskDictionary: d)
}
if let _ = d[folderIDKey] {
return LocalFolder(account: self, diskDictionary: d)
}
return nil
}
func childrenForDiskArray(_ children: NSArray) -> [Any] {
var items = [Any]()
children.forEach { (oneChild) in
guard let oneDictionary = oneChild as? NSDictionary else {
return
}
if let oneObject = objectWithDiskDictionary(oneDictionary) {
items.append(oneObject)
}
}
return items
}
private func pullTopLevelItemsFromArray(_ children: NSArray) {
let items = childrenForDiskArray(children)
items.forEach { (oneItem) in
if let oneFolder = oneItem as? LocalFolder {
topLevelFolders[oneFolder.folderID] = oneFolder
}
else if let oneFeed = oneItem as? LocalFeed {
topLevelFeeds[oneFeed.feedID] = oneFeed
}
}
}
// MARK: OPML Export
public func opmlString(indentLevel: Int) -> String {
var s = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
s += "<opml version=\"1.1\">\n"
s += "<head>\n"
s += "\t<title>mySubscriptions</title>\n"
s += "\t</head>\n"
s += "\t<body>\n"
let indentLevel = 1
let _ = visitChildren { (oneChild) -> Bool in
if let oneFolder = oneChild as? LocalFolder {
s += oneFolder.opmlString(indentLevel: indentLevel)
}
else if let oneFeed = oneChild as? LocalFeed {
s += oneFeed.opmlString(indentLevel: indentLevel)
}
return false
}
s += "\t</body>\n"
s += "</opml>\n"
return s
}
}
private extension LocalAccount {
// MARK: Deleting
func deleteItem(_ item: AnyObject) {
if let feed = item as? LocalFeed {
deleteFeed(feed)
}
else if let folder = item as? LocalFolder {
deleteFolder(folder)
}
}
func deleteFeed(_ feed: LocalFeed) {
topLevelFeeds[feed.feedID] = nil
}
func deleteFolder(_ folder: LocalFolder) {
topLevelFolders[folder.folderID] = nil
}
// MARK: Unread Counts
func updateUnreadCountsForFeeds(_ feeds: Set<LocalFeed>) {
let feedIDs = feeds.map { $0.feedID }
updateUnreadCounts(feedIDs: Set(feedIDs))
}
func updateUnreadCountsForTopLevelFolders() {
topLevelFolders.allValues.forEach { (oneFolder) in
if let oneFolder = oneFolder as? UnreadCountProvider {
oneFolder.updateUnreadCount()
}
}
}
// MARK: OPML Import
func importOPMLItems(_ items: [RSOPMLItem]) {
// FeedBins OPML duplicates everything in a folder onto the top level.
// So: do the folders first, then the top level feeds.
importOPMLTopLevelFolders(items)
importOPMLTopLevelFeeds(items)
}
func importOPMLTopLevelFolders(_ items: [RSOPMLItem]) {
for oneItem in items {
if oneItem.isFolder, let childItems = oneItem.children as? [RSOPMLItem] {
importOPMLTopLevelFolder(oneItem, childItems)
}
}
}
func importOPMLTopLevelFeeds(_ items: [RSOPMLItem]) {
for oneItem in items {
if !oneItem.isFolder {
importOPMLFeedIntoFolder(oneItem, nil)
}
}
}
func importOPMLTopLevelFolder(_ opmlFolder: RSOPMLItem, _ items: [RSOPMLItem]) {
let folderTitle = opmlFolder.titleFromAttributes ?? "Untitled"
let folder = ensureFolderWithName(folderTitle)! as! LocalFolder
importOPMLItemsIntoFolder(items, folder)
let _ = addItem(folder)
}
func importOPMLItemsIntoFolder(_ items: [RSOPMLItem], _ folder: LocalFolder) {
// nil folder for top level.
for oneItem in items {
if oneItem.isFolder, let childItems = oneItem.children as? [RSOPMLItem] {
importOPMLItemsIntoFolder(childItems, folder)
continue
}
else {
importOPMLFeedIntoFolder(oneItem, folder)
}
}
}
func importOPMLFeedIntoFolder(_ opmlFeed: RSOPMLItem, _ folder: LocalFolder?) {
guard let feedSpecifier = opmlFeed.opmlFeedSpecifier, let feedURL = feedSpecifier.feedURL else {
return
}
if let _ = existingFeedWithURL(feedURL) {
return
}
let feed = LocalFeed(account: self, url: feedURL, feedID: feedURL)
if let name = feedSpecifier.title {
feed.editedName = name
}
if let folder = folder {
let _ = folder.addItem(feed)
}
else {
let _ = addItem(feed)
}
}
// MARK: Unread Counts
func updateUnreadCountsWithDatabaseDictionary(_ unreadCountsDictionary: [String: Int]) {
for oneFeed in flattenedFeeds {
guard let oneFeed = oneFeed as? LocalFeed, let unreadCount = unreadCountsDictionary[oneFeed.feedID] else {
continue
}
if oneFeed.unreadCount != unreadCount {
oneFeed.unreadCount = unreadCount
}
}
updateUnreadCountsForTopLevelFolders()
}
func updateUnreadCounts(feedIDs: Set<String>) {
self.localDatabase.updateUnreadCounts(for: Set(feedIDs), completion: { (unreadCounts) in
self.updateUnreadCountsWithDatabaseDictionary(unreadCounts)
})
}
}

View File

@ -0,0 +1,482 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
8471A2DD1ED4CFE4008F099E /* LocalAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2DC1ED4CFE4008F099E /* LocalAccount.swift */; };
8471A2DF1ED4CFEB008F099E /* LocalAccountRefresher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2DE1ED4CFEB008F099E /* LocalAccountRefresher.swift */; };
8471A2E11ED4CFF3008F099E /* LocalFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2E01ED4CFF3008F099E /* LocalFolder.swift */; };
8471A2E31ED4CFFB008F099E /* LocalFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2E21ED4CFFB008F099E /* LocalFeed.swift */; };
8471A2E51ED4D007008F099E /* LocalArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2E41ED4D007008F099E /* LocalArticle.swift */; };
8471A2E71ED4D012008F099E /* LocalArticleStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2E61ED4D012008F099E /* LocalArticleStatus.swift */; };
8471A2E91ED4D01B008F099E /* DiskDictionaryConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2E81ED4D01B008F099E /* DiskDictionaryConstants.swift */; };
8471A2FA1ED4D098008F099E /* LocalArticleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2F91ED4D098008F099E /* LocalArticleCache.swift */; };
8471A2FC1ED4D0A1008F099E /* LocalDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2FB1ED4D0A1008F099E /* LocalDatabase.swift */; };
8471A2FE1ED4D0AD008F099E /* LocalStatusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471A2FD1ED4D0AD008F099E /* LocalStatusesManager.swift */; };
8471A3001ED4D0B8008F099E /* LocalCreateStatements.sql in Resources */ = {isa = PBXBuildFile; fileRef = 8471A2FF1ED4D0B8008F099E /* LocalCreateStatements.sql */; };
84C7AEB11D68C79A009FB883 /* LocalAccount.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84C7AEA71D68C79A009FB883 /* LocalAccount.framework */; };
84C7AEB61D68C79A009FB883 /* LocalAccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C7AEB51D68C79A009FB883 /* LocalAccountTests.swift */; };
84D37AD81D68CFD500110870 /* DataModel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D37AD71D68CFD500110870 /* DataModel.framework */; };
84D37ADA1D68CFEE00110870 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D37AD91D68CFEE00110870 /* RSCore.framework */; };
84D37ADC1D68CFFC00110870 /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D37ADB1D68CFFC00110870 /* RSDatabase.framework */; };
84D37ADE1D68D00700110870 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D37ADD1D68D00700110870 /* RSXML.framework */; };
84D37AE21D68D07700110870 /* RSWeb.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84D37AE11D68D07700110870 /* RSWeb.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
84C7AEB21D68C79A009FB883 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 84C7AE9E1D68C79A009FB883 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 84C7AEA61D68C79A009FB883;
remoteInfo = LocalAccount;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
8471A2DC1ED4CFE4008F099E /* LocalAccount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalAccount.swift; sourceTree = "<group>"; };
8471A2DE1ED4CFEB008F099E /* LocalAccountRefresher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalAccountRefresher.swift; sourceTree = "<group>"; };
8471A2E01ED4CFF3008F099E /* LocalFolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalFolder.swift; sourceTree = "<group>"; };
8471A2E21ED4CFFB008F099E /* LocalFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalFeed.swift; sourceTree = "<group>"; };
8471A2E41ED4D007008F099E /* LocalArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalArticle.swift; sourceTree = "<group>"; };
8471A2E61ED4D012008F099E /* LocalArticleStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalArticleStatus.swift; sourceTree = "<group>"; };
8471A2E81ED4D01B008F099E /* DiskDictionaryConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiskDictionaryConstants.swift; sourceTree = "<group>"; };
8471A2EA1ED4D02F008F099E /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
8471A2F91ED4D098008F099E /* LocalArticleCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalArticleCache.swift; sourceTree = "<group>"; };
8471A2FB1ED4D0A1008F099E /* LocalDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalDatabase.swift; sourceTree = "<group>"; };
8471A2FD1ED4D0AD008F099E /* LocalStatusesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalStatusesManager.swift; sourceTree = "<group>"; };
8471A2FF1ED4D0B8008F099E /* LocalCreateStatements.sql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LocalCreateStatements.sql; sourceTree = "<group>"; };
84C7AEA71D68C79A009FB883 /* LocalAccount.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LocalAccount.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84C7AEB01D68C79A009FB883 /* LocalAccountTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LocalAccountTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
84C7AEB51D68C79A009FB883 /* LocalAccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountTests.swift; sourceTree = "<group>"; };
84C7AEB71D68C79A009FB883 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
84D37AD71D68CFD500110870 /* DataModel.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DataModel.framework; path = "../../../../../Library/Developer/Xcode/DerivedData/Rainier-cidsoqwawkdqqphkdtrqrojskege/Build/Products/Debug/DataModel.framework"; sourceTree = "<group>"; };
84D37AD91D68CFEE00110870 /* RSCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSCore.framework; path = "../../../../../Library/Developer/Xcode/DerivedData/Rainier-cidsoqwawkdqqphkdtrqrojskege/Build/Products/Debug/RSCore.framework"; sourceTree = "<group>"; };
84D37ADB1D68CFFC00110870 /* RSDatabase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSDatabase.framework; path = "../../../../../Library/Developer/Xcode/DerivedData/Rainier-cidsoqwawkdqqphkdtrqrojskege/Build/Products/Debug/RSDatabase.framework"; sourceTree = "<group>"; };
84D37ADD1D68D00700110870 /* RSXML.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML.framework; path = "../../../../../Library/Developer/Xcode/DerivedData/Rainier-cidsoqwawkdqqphkdtrqrojskege/Build/Products/Debug/RSXML.framework"; sourceTree = "<group>"; };
84D37AE11D68D07700110870 /* RSWeb.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSWeb.framework; path = "../../../../../Library/Developer/Xcode/DerivedData/Rainier-cidsoqwawkdqqphkdtrqrojskege/Build/Products/Debug/RSWeb.framework"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
84C7AEA31D68C79A009FB883 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
84D37AE21D68D07700110870 /* RSWeb.framework in Frameworks */,
84D37ADE1D68D00700110870 /* RSXML.framework in Frameworks */,
84D37ADC1D68CFFC00110870 /* RSDatabase.framework in Frameworks */,
84D37ADA1D68CFEE00110870 /* RSCore.framework in Frameworks */,
84D37AD81D68CFD500110870 /* DataModel.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
84C7AEAD1D68C79A009FB883 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
84C7AEB11D68C79A009FB883 /* LocalAccount.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
8442EB3F1D68C8B200D709AE /* Database */ = {
isa = PBXGroup;
children = (
8471A2F91ED4D098008F099E /* LocalArticleCache.swift */,
8471A2FB1ED4D0A1008F099E /* LocalDatabase.swift */,
8471A2FD1ED4D0AD008F099E /* LocalStatusesManager.swift */,
8471A2FF1ED4D0B8008F099E /* LocalCreateStatements.sql */,
);
name = Database;
sourceTree = "<group>";
};
84C7AE9D1D68C79A009FB883 = {
isa = PBXGroup;
children = (
8471A2DC1ED4CFE4008F099E /* LocalAccount.swift */,
8471A2DE1ED4CFEB008F099E /* LocalAccountRefresher.swift */,
8471A2E01ED4CFF3008F099E /* LocalFolder.swift */,
8471A2E21ED4CFFB008F099E /* LocalFeed.swift */,
8471A2E41ED4D007008F099E /* LocalArticle.swift */,
8471A2E61ED4D012008F099E /* LocalArticleStatus.swift */,
8471A2E81ED4D01B008F099E /* DiskDictionaryConstants.swift */,
8442EB3F1D68C8B200D709AE /* Database */,
8471A2EA1ED4D02F008F099E /* Info.plist */,
84C7AEB41D68C79A009FB883 /* LocalAccountTests */,
84C7AEA81D68C79A009FB883 /* Products */,
84D37AD61D68CFD500110870 /* Frameworks */,
);
sourceTree = "<group>";
};
84C7AEA81D68C79A009FB883 /* Products */ = {
isa = PBXGroup;
children = (
84C7AEA71D68C79A009FB883 /* LocalAccount.framework */,
84C7AEB01D68C79A009FB883 /* LocalAccountTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
84C7AEB41D68C79A009FB883 /* LocalAccountTests */ = {
isa = PBXGroup;
children = (
84C7AEB51D68C79A009FB883 /* LocalAccountTests.swift */,
84C7AEB71D68C79A009FB883 /* Info.plist */,
);
path = LocalAccountTests;
sourceTree = "<group>";
};
84D37AD61D68CFD500110870 /* Frameworks */ = {
isa = PBXGroup;
children = (
84D37AE11D68D07700110870 /* RSWeb.framework */,
84D37ADD1D68D00700110870 /* RSXML.framework */,
84D37ADB1D68CFFC00110870 /* RSDatabase.framework */,
84D37AD91D68CFEE00110870 /* RSCore.framework */,
84D37AD71D68CFD500110870 /* DataModel.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
84C7AEA41D68C79A009FB883 /* Headers */ = {
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXHeadersBuildPhase section */
/* Begin PBXNativeTarget section */
84C7AEA61D68C79A009FB883 /* LocalAccount */ = {
isa = PBXNativeTarget;
buildConfigurationList = 84C7AEBB1D68C79A009FB883 /* Build configuration list for PBXNativeTarget "LocalAccount" */;
buildPhases = (
84C7AEA21D68C79A009FB883 /* Sources */,
84C7AEA31D68C79A009FB883 /* Frameworks */,
84C7AEA41D68C79A009FB883 /* Headers */,
84C7AEA51D68C79A009FB883 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = LocalAccount;
productName = LocalAccount;
productReference = 84C7AEA71D68C79A009FB883 /* LocalAccount.framework */;
productType = "com.apple.product-type.framework";
};
84C7AEAF1D68C79A009FB883 /* LocalAccountTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 84C7AEBE1D68C79A009FB883 /* Build configuration list for PBXNativeTarget "LocalAccountTests" */;
buildPhases = (
84C7AEAC1D68C79A009FB883 /* Sources */,
84C7AEAD1D68C79A009FB883 /* Frameworks */,
84C7AEAE1D68C79A009FB883 /* Resources */,
);
buildRules = (
);
dependencies = (
84C7AEB31D68C79A009FB883 /* PBXTargetDependency */,
);
name = LocalAccountTests;
productName = LocalAccountTests;
productReference = 84C7AEB01D68C79A009FB883 /* LocalAccountTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
84C7AE9E1D68C79A009FB883 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0800;
LastUpgradeCheck = 0800;
ORGANIZATIONNAME = "Ranchero Software, LLC";
TargetAttributes = {
84C7AEA61D68C79A009FB883 = {
CreatedOnToolsVersion = 8.0;
LastSwiftMigration = 0800;
ProvisioningStyle = Automatic;
};
84C7AEAF1D68C79A009FB883 = {
CreatedOnToolsVersion = 8.0;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 84C7AEA11D68C79A009FB883 /* Build configuration list for PBXProject "LocalAccount" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = English;
hasScannedForEncodings = 0;
knownRegions = (
en,
);
mainGroup = 84C7AE9D1D68C79A009FB883;
productRefGroup = 84C7AEA81D68C79A009FB883 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
84C7AEA61D68C79A009FB883 /* LocalAccount */,
84C7AEAF1D68C79A009FB883 /* LocalAccountTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
84C7AEA51D68C79A009FB883 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8471A3001ED4D0B8008F099E /* LocalCreateStatements.sql in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
84C7AEAE1D68C79A009FB883 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
84C7AEA21D68C79A009FB883 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8471A2E91ED4D01B008F099E /* DiskDictionaryConstants.swift in Sources */,
8471A2E11ED4CFF3008F099E /* LocalFolder.swift in Sources */,
8471A2E51ED4D007008F099E /* LocalArticle.swift in Sources */,
8471A2FA1ED4D098008F099E /* LocalArticleCache.swift in Sources */,
8471A2E71ED4D012008F099E /* LocalArticleStatus.swift in Sources */,
8471A2FC1ED4D0A1008F099E /* LocalDatabase.swift in Sources */,
8471A2E31ED4CFFB008F099E /* LocalFeed.swift in Sources */,
8471A2FE1ED4D0AD008F099E /* LocalStatusesManager.swift in Sources */,
8471A2DD1ED4CFE4008F099E /* LocalAccount.swift in Sources */,
8471A2DF1ED4CFEB008F099E /* LocalAccountRefresher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
84C7AEAC1D68C79A009FB883 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
84C7AEB61D68C79A009FB883 /* LocalAccountTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
84C7AEB31D68C79A009FB883 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 84C7AEA61D68C79A009FB883 /* LocalAccount */;
targetProxy = 84C7AEB21D68C79A009FB883 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
84C7AEB91D68C79A009FB883 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_SUSPICIOUS_MOVES = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Debug;
};
84C7AEBA1D68C79A009FB883 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_SUSPICIOUS_MOVES = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Release;
};
84C7AEBC1D68C79A009FB883 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
COMBINE_HIDPI_IMAGES = YES;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_VERSION = A;
INFOPLIST_FILE = Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.LocalAccount;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 3.0;
};
name = Debug;
};
84C7AEBD1D68C79A009FB883 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "";
COMBINE_HIDPI_IMAGES = YES;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_VERSION = A;
INFOPLIST_FILE = Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.LocalAccount;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_VERSION = 3.0;
};
name = Release;
};
84C7AEBF1D68C79A009FB883 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = LocalAccountTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.LocalAccountTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 3.0;
};
name = Debug;
};
84C7AEC01D68C79A009FB883 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = LocalAccountTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.LocalAccountTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 3.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
84C7AEA11D68C79A009FB883 /* Build configuration list for PBXProject "LocalAccount" */ = {
isa = XCConfigurationList;
buildConfigurations = (
84C7AEB91D68C79A009FB883 /* Debug */,
84C7AEBA1D68C79A009FB883 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
84C7AEBB1D68C79A009FB883 /* Build configuration list for PBXNativeTarget "LocalAccount" */ = {
isa = XCConfigurationList;
buildConfigurations = (
84C7AEBC1D68C79A009FB883 /* Debug */,
84C7AEBD1D68C79A009FB883 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
84C7AEBE1D68C79A009FB883 /* Build configuration list for PBXNativeTarget "LocalAccountTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
84C7AEBF1D68C79A009FB883 /* Debug */,
84C7AEC01D68C79A009FB883 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 84C7AE9E1D68C79A009FB883 /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:LocalAccount.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,130 @@
//
// LocalAccountRefresher.swift
// LocalAccount
//
// Created by Brent Simmons on 9/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSXML
import RSWeb
final class LocalAccountRefresher: DownloadSessionDelegate {
weak var account: LocalAccount?
private lazy var downloadSession: DownloadSession = {
return DownloadSession(delegate: self)
}()
var progress: DownloadProgress {
get {
return downloadSession.progress
}
}
public func refreshFeeds(_ feeds: NSSet) {
downloadSession.refreshObjects(feeds)
}
// MARK: DownloadSessionDelegate
public func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject representedObject: AnyObject) -> URLRequest? {
guard let feed = representedObject as? LocalFeed else {
return nil
}
guard let url = URL(string: feed.url) else {
return nil
}
let request = NSMutableURLRequest(url: url)
if let conditionalGetInfo = feed.conditionalGetInfo, !conditionalGetInfo.isEmpty {
conditionalGetInfo.addRequestHeadersToURLRequest(request)
}
return request as URLRequest
}
public func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?) {
guard let feed = representedObject as? LocalFeed, !data.isEmpty else {
return
}
if let error = error {
print("Error downloading \(feed.url) - \(error)")
return
}
let dataHash = (data as NSData).rs_md5HashString()
if dataHash == feed.contentHash {
// print("Hashed content of \(feed.url) has not changed.")
return
}
let xmlData = RSXMLData(data: data, urlString: feed.url)
RSParseFeed(xmlData) { (parsedFeed, error) in
guard let account = self.account, let parsedFeed = parsedFeed, error == nil else {
return
}
account.update(feed, parsedFeed: parsedFeed) {
if let httpResponse = response as? HTTPURLResponse {
let conditionalGetInfo = HTTPConditionalGetInfo(URLResponse: httpResponse)
if !conditionalGetInfo.isEmpty || feed.conditionalGetInfo != nil {
feed.conditionalGetInfo = conditionalGetInfo
}
}
feed.contentHash = dataHash
}
}
}
public func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool {
guard let feed = representedObject as? LocalFeed else {
return false
}
if data.isEmpty {
return true
}
if let mimeType = RSMimeTypeForData(data), RSMimeTypeIsMedia(mimeType) {
return false
}
if data.count > 4096 {
let xmlData = RSXMLData(data: data, urlString: feed.url)
return RSCanParseFeed(xmlData)
}
return true
}
func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) {
// guard let feed = representedObject as? LocalFeed else {
// return
// }
//
// print("Unexpected response \(response) for \(feed.url).")
}
func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) {
// guard let feed = representedObject as? LocalFeed else {
// return
// }
//
// print("Not modified response for \(feed.url).")
}
}

View File

@ -0,0 +1,22 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@ -0,0 +1,36 @@
//
// LocalAccountTests.swift
// LocalAccountTests
//
// Created by Brent Simmons on 8/20/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import LocalAccount
class LocalAccountTests: XCTestCase {
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
super.tearDown()
}
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
func testPerformanceExample() {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@ -0,0 +1,196 @@
//
// LocalArticle.swift
// Rainier
//
// Created by Brent Simmons on 4/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import DataModel
public final class LocalArticle: NSObject, Article {
public let account: Account?
public let feedID: String
public let articleID: String
public var status: ArticleStatus!
public var guid: String?
public var title: String?
private var _body: String?
fileprivate var bodyData: Data?
public var body: String? {
get {
if _body == nil, let d = bodyData {
// print(title)
if let s = NSString(data: d, encoding: String.Encoding.utf8.rawValue) as? String {
_body = s
bodyData = nil
}
}
return _body
}
set {
_body = newValue
}
}
public var link: String?
public var permalink: String?
public var author: String?
public var datePublished: Date?
public var dateModified: Date?
private let _hash: Int
public override var hashValue: Int {
get {
return _hash
}
}
public init(account: Account, feedID: String, articleID: String) {
self.account = account
self.feedID = feedID
self.articleID = articleID
self._hash = articleID.hashValue
}
public override func isEqual(_ object: Any?) -> Bool {
if let otherArticle = object as? LocalArticle {
return otherArticle === self
}
return false
}
// static public func ==(lhs: LocalArticle, rhs: LocalArticle) -> Bool {
//
// if lhs === rhs {
// return true
// }
//
// return lhs.hashValue == rhs.hashValue && lhs.articleID == rhs.articleID && lhs.feedID == rhs.feedID && lhs.guid == rhs.guid && lhs.title == rhs.title && lhs.body == rhs.body && lhs.link == rhs.link && lhs.permalink == rhs.permalink && lhs.author == rhs.author && lhs.datePublished == rhs.datePublished && lhs.status == rhs.status;
// }
}
// MARK: LocalDatabase support
import RSCore
import RSDatabase
import RSXML
// Database columns *and* value keys.
let articleIDKey = "articleID"
private let articleFeedIDKey = "feedID"
private let articleGuidKey = "guid"
private let articleTitleKey = "title"
private let articleBodyKey = "body"
private let articleDatePublishedKey = "datePublished"
private let articleDateModifiedKey = "dateModified"
private let articleLinkKey = "link"
private let articlePermalinkKey = "permalink"
private let articleAuthorKey = "author"
private let mergeablePropertyNames = [articleGuidKey, articleTitleKey, articleBodyKey, articleDatePublishedKey, articleDateModifiedKey, articleLinkKey, articlePermalinkKey, articleAuthorKey]
public extension LocalArticle {
convenience init(account: Account, feedID: String, parsedArticle: RSParsedArticle) {
self.init(account: account, feedID: feedID, articleID: parsedArticle.articleID)
self.guid = parsedArticle.guid
self.title = parsedArticle.title
self.body = parsedArticle.body
self.datePublished = parsedArticle.datePublished
self.dateModified = parsedArticle.dateModified
self.link = parsedArticle.link
self.permalink = parsedArticle.permalink
self.author = parsedArticle.author
}
private enum ColumnIndex: Int {
case articleID = 0, feedID, guid, title, body, datePublished, dateModified, link, permalink, author
}
convenience init?(account: Account, row: FMResultSet) {
guard let articleID = row.string(forColumnIndex: Int32(ColumnIndex.articleID.rawValue)), let feedID = row.string(forColumnIndex: Int32(ColumnIndex.feedID.rawValue)) else {
return nil
}
self.init(account: account, feedID: feedID, articleID: articleID)
self.guid = row.string(forColumnIndex: Int32(ColumnIndex.guid.rawValue))
self.title = row.string(forColumnIndex: Int32(ColumnIndex.title.rawValue))
self.bodyData = row.data(forColumnIndex: Int32(ColumnIndex.body.rawValue))
self.datePublished = row.date(forColumnIndex: Int32(ColumnIndex.datePublished.rawValue))
self.dateModified = row.date(forColumnIndex: Int32(ColumnIndex.dateModified.rawValue))
self.link = row.string(forColumnIndex: Int32(ColumnIndex.link.rawValue))
self.permalink = row.string(forColumnIndex: Int32(ColumnIndex.permalink.rawValue))
self.author = row.string(forColumnIndex: Int32(ColumnIndex.author.rawValue))
}
var databaseDictionary: NSDictionary {
get {
return createDatabaseDictionary()
}
}
func updateWithParsedArticle(_ parsedArticle: RSParsedArticle) -> NSDictionary? {
let d: NSDictionary = rs_mergeValues(withObjectReturningChanges: parsedArticle, propertyNames: mergeablePropertyNames) as NSDictionary
if d.count < 1 {
return nil
}
let databaseDictionary: NSMutableDictionary = d.mutableCopy() as! NSMutableDictionary
databaseDictionary[articleIDKey] = articleIDKey
return databaseDictionary
}
private func createDatabaseDictionary() -> NSDictionary {
// Includes only non-nil values.
let d = NSMutableDictionary()
d[articleIDKey] = articleID
d[articleFeedIDKey] = feedID
if let guid = self.guid {
d[articleGuidKey] = guid
}
if let title = self.title {
d[articleTitleKey] = title
}
if let body = self.body {
d[articleBodyKey] = body
}
if let datePublished = self.datePublished {
d[articleDatePublishedKey] = datePublished
}
if let dateModified = self.dateModified {
d[articleDateModifiedKey] = dateModified
}
if let link = self.link {
d[articleLinkKey] = link
}
if let permalink = self.permalink {
d[articlePermalinkKey] = permalink
}
if let author = self.author {
d[articleAuthorKey] = author
}
return d
}
}

View File

@ -0,0 +1,94 @@
//
// LocalArticleCache.swift
// Rainier
//
// Created by Brent Simmons on 5/9/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
final class LocalArticleCache {
private var cachedArticles: NSMapTable<NSString, LocalArticle> = NSMapTable.weakToWeakObjects()
// private var cachedArticles = [String: LocalArticle]()
// fileprivate var articlesByFeedID = [String: Set<LocalArticle>]()
private let statusesManager: LocalStatusesManager
init(statusesManager: LocalStatusesManager) {
self.statusesManager = statusesManager
}
func uniquedArticles(_ fetchedArticles: Set<LocalArticle>) -> Set<LocalArticle> {
var articles = Set<LocalArticle>()
for oneArticle in fetchedArticles {
assert(oneArticle.status != nil)
if let existingArticle = cachedArticle(oneArticle.articleID) {
articles.insert(existingArticle)
}
else {
cacheArticle(oneArticle)
articles.insert(oneArticle)
}
}
statusesManager.attachCachedUniqueStatuses(articles)
return articles
}
func cachedArticle(_ articleID: String) -> LocalArticle? {
return cachedArticles.object(forKey: articleID as NSString)
// return cachedArticles[articleID]
}
func cacheArticle(_ article: LocalArticle) {
cachedArticles.setObject(article, forKey: article.articleID as NSString)
// cachedArticles[article.articleID] = article
// addToCachedArticlesForFeedID(Set([article]))
}
func cacheArticles(_ articles: Set<LocalArticle>) {
articles.forEach { cacheArticle($0) }
// addToCachedArticlesForFeedID(articles)
}
// func cachedArticlesForFeedID(_ feedID: String) -> Set<LocalArticle>? {
//
// return articlesByFeedID[feedID]
// }
}
//private extension LocalArticleCache {
//
// func addToCachedArticlesForFeedID(_ feedID: String, _ articles: Set<LocalArticle>) {
//
// if let cachedArticles = cachedArticlesForFeedID(feedID) {
// replaceCachedArticlesForFeedID(feedID, cachedArticles.union(articles))
// }
// else {
// replaceCachedArticlesForFeedID(feedID, articles)
// }
// }
//
// func addToCachedArticlesForFeedID(_ articles: Set<LocalArticle>) {
//
// for oneArticle in articles {
// addToCachedArticlesForFeedID(oneArticle.feedID, Set([oneArticle]))
// }
// }
//
// func replaceCachedArticlesForFeedID(_ feedID: String, _ articles: Set<LocalArticle>) {
//
// articlesByFeedID[feedID] = articles
// }
//
//}

View File

@ -0,0 +1,119 @@
//
// LocalArticleStatus.swift
// Rainier
//
// Created by Brent Simmons on 4/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSDatabase
import DataModel
public final class LocalArticleStatus: ArticleStatus, Hashable {
public var read = false
public var starred = false
public var userDeleted = false
public let dateArrived: Date
public let hashValue: Int
let articleID: String
public init(articleID: String, read: Bool, starred: Bool, userDeleted: Bool, dateArrived: Date) {
self.articleID = articleID
self.hashValue = articleID.hashValue
self.read = read
self.starred = starred
self.userDeleted = userDeleted
self.dateArrived = dateArrived
}
// MARK: ArticleStatus
public func setBoolStatusForKey(_ status: Bool, articleStatusKey: ArticleStatusKey) {
switch articleStatusKey {
case .read:
read = status
case .starred:
starred = status
case .userDeleted:
userDeleted = status
}
}
public func boolStatusForKey(_ articleStatusKey: ArticleStatusKey) -> Bool {
switch articleStatusKey {
case .read:
return read
case .starred:
return starred
case .userDeleted:
return userDeleted
}
}
}
public func ==(lhs: LocalArticleStatus, rhs: LocalArticleStatus) -> Bool {
if lhs === rhs {
return true
}
return lhs.hashValue == rhs.hashValue && lhs.articleID == rhs.articleID && lhs.read == rhs.read && lhs.starred == rhs.starred && lhs.userDeleted == rhs.userDeleted && lhs.dateArrived == rhs.dateArrived
}
// LocalDatabase use.
// Database columns.
private let articleStatusIDKey = "articleID"
private let articleStatusReadKey = "read"
private let articleStatusStarredKey = "starred"
private let articleStatusUserDeletedKey = "userDeleted"
private let articleStatusDateArrivedKey = "dateArrived"
extension LocalArticleStatus {
convenience init?(row: FMResultSet) {
let articleID = row.string(forColumn: articleStatusIDKey)
if (articleID == nil) {
return nil
}
let read = row.bool(forColumn: articleStatusReadKey)
let starred = row.bool(forColumn: articleStatusStarredKey)
let userDeleted = row.bool(forColumn: articleStatusUserDeletedKey)
var dateArrived = row.date(forColumn: articleStatusDateArrivedKey)
if (dateArrived == nil) {
dateArrived = NSDate.distantPast
}
self.init(articleID: articleID!, read: read, starred: starred, userDeleted: userDeleted, dateArrived: dateArrived!)
}
var databaseDictionary: NSDictionary {
get {
return createDatabaseDictionary()
}
}
private func createDatabaseDictionary() -> NSDictionary {
let d = NSMutableDictionary()
d[articleIDKey] = articleID
d[articleStatusReadKey] = read
d[articleStatusStarredKey] = starred
d[articleStatusUserDeletedKey] = userDeleted
d[articleStatusDateArrivedKey] = dateArrived
return d.copy() as! NSDictionary
}
}

View File

@ -0,0 +1,9 @@
/*articleID is a hash of [something]+feedID. When there's a guid, [something] is a guid. Otherwise it's a combination of non-null properties.*/
CREATE TABLE if not EXISTS articles (articleID TEXT NOT NULL PRIMARY KEY, feedID TEXT NOT NULL, guid TEXT, title TEXT, body TEXT, datePublished DATE, dateModified DATE, link TEXT, permalink TEXT, author TEXT);
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);
/*Indexes*/
CREATE INDEX if not EXISTS feedIndex on articles (feedID);

View File

@ -0,0 +1,553 @@
//
// LocalDatabase.swift
// Rainier
//
// Created by Brent Simmons on 7/20/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSXML
import RSDatabase
import DataModel
let sqlLogging = false
func logSQL(_ sql: String) {
if sqlLogging {
print("SQL: \(sql)")
}
}
typealias LocalArticleResultBlock = (Set<LocalArticle>) -> Void
private let articlesTableName = "articles"
final class LocalDatabase {
fileprivate let queue: RSDatabaseQueue
private let databaseFile: String
fileprivate let statusesManager: LocalStatusesManager
fileprivate let articleCache: LocalArticleCache
fileprivate var articleArrivalCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)!
fileprivate let minimumNumberOfArticles = 10
var account: LocalAccount!
init(databaseFile: String) {
self.databaseFile = databaseFile
self.queue = RSDatabaseQueue(filepath: databaseFile, excludeFromBackup: false)
self.statusesManager = LocalStatusesManager(queue: self.queue)
self.articleCache = LocalArticleCache(statusesManager: self.statusesManager)
let createStatementsPath = Bundle(for: type(of: self)).path(forResource: "LocalCreateStatements", ofType: "sql")!
let createStatements = try! NSString(contentsOfFile: createStatementsPath, encoding: String.Encoding.utf8.rawValue)
queue.createTables(usingStatements: createStatements as String)
queue.run { (database) in
let _ = database.executeUpdate("drop index dateArrivedIndex;", withArgumentsIn: [])
}
queue.vacuumIfNeeded()
}
// MARK: API
func startup() {
assert(account != nil)
// deleteOldArticles(articleIDsInFeeds)
}
// MARK: Fetching Articles
func fetchArticlesForFeed(_ feed: LocalFeed) -> Set<LocalArticle> {
// if let articles = articleCache.cachedArticlesForFeedID(feed.feedID) {
// return articles
// }
var fetchedArticles = Set<LocalArticle>()
let feedID = feed.feedID
queue.fetchSync { (database: FMDatabase!) -> Void in
fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
}
let articles = articleCache.uniquedArticles(fetchedArticles)
return filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
}
func fetchArticlesForFeedAsync(_ feed: LocalFeed, _ resultBlock: @escaping LocalArticleResultBlock) {
// if let articles = articleCache.cachedArticlesForFeedID(feed.feedID) {
// resultBlock(articles)
// return
// }
let feedID = feed.feedID
queue.fetch { (database: FMDatabase!) -> Void in
let fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
DispatchQueue.main.async() { () -> Void in
let articles = self.articleCache.uniquedArticles(fetchedArticles)
let filteredArticles = self.filteredArticles(articles, feedCounts: [feed.feedID: fetchedArticles.count])
resultBlock(filteredArticles)
}
}
}
func feedIDCountDictionariesWithResultSet(_ resultSet: FMResultSet) -> [String: Int] {
var counts = [String: Int]()
while (resultSet.next()) {
if let oneFeedID = resultSet.string(forColumnIndex: 0) {
let count = resultSet.int(forColumnIndex: 1)
counts[oneFeedID] = Int(count)
}
}
return counts
}
func countsForAllFeeds(_ database: FMDatabase) -> [String: Int] {
let sql = "select distinct feedID, count(*) as count from articles group by feedID;"
if let resultSet = database.executeQuery(sql, withArgumentsIn: []) {
return feedIDCountDictionariesWithResultSet(resultSet)
}
return [String: Int]()
}
func countsForFeedIDs(_ feedIDs: [String], _ database: FMDatabase) -> [String: Int] {
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let sql = "select distinct feedID, count(*) from articles where feedID in \(placeholders) group by feedID;"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: feedIDs) {
return feedIDCountDictionariesWithResultSet(resultSet)
}
return [String: Int]()
}
func fetchUnreadArticlesForFolder(_ folder: LocalFolder) -> Set<LocalArticle> {
return fetchUnreadArticlesForFeedIDs(Array(folder.flattenedFeedIDs))
}
func fetchUnreadArticlesForFeedIDs(_ feedIDs: [String]) -> Set<LocalArticle> {
if feedIDs.isEmpty {
return Set<LocalArticle>()
}
var fetchedArticles = Set<LocalArticle>()
var counts = [String: Int]()
queue.fetchSync { (database: FMDatabase!) -> Void in
counts = self.countsForFeedIDs(feedIDs, database)
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read = 0
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
let sql = "select * from articles natural join statuses where feedID in \(placeholders) and read=0;"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: feedIDs) {
fetchedArticles = self.articlesWithResultSet(resultSet)
}
}
let articles = articleCache.uniquedArticles(fetchedArticles)
return filteredArticles(articles, feedCounts: counts)
}
typealias UnreadCountCompletionBlock = ([String: Int]) -> Void //feedID: unreadCount
func updateUnreadCounts(for feedIDs: Set<String>, completion: @escaping UnreadCountCompletionBlock) {
queue.fetch { (database: FMDatabase!) -> Void in
var unreadCounts = [String: Int]()
for oneFeedID in feedIDs {
unreadCounts[oneFeedID] = self.unreadCount(oneFeedID, database)
}
DispatchQueue.main.async() { () -> Void in
completion(unreadCounts)
}
}
}
// MARK: Updating Articles
func updateFeedWithParsedFeed(_ feed: LocalFeed, parsedFeed: RSParsedFeed, completionHandler: @escaping RSVoidCompletionBlock) {
if parsedFeed.articles.isEmpty {
completionHandler()
return
}
let parsedArticlesDictionary = self.articlesDictionary(parsedFeed.articles as NSSet) as! [String: RSParsedArticle]
fetchArticlesForFeedAsync(feed) { (articles) -> Void in
let articlesDictionary = self.articlesDictionary(articles as NSSet) as! [String: LocalArticle]
self.updateArticles(articlesDictionary, parsedArticles: parsedArticlesDictionary, feed: feed, completionHandler: completionHandler)
}
}
// MARK: Status
func markArticles(_ articles: NSSet, statusKey: ArticleStatusKey, flag: Bool) {
statusesManager.markArticles(articles as! Set<LocalArticle>, statusKey: statusKey, flag: flag)
}
}
// MARK: Private
private extension LocalDatabase {
// MARK: Saving Articles
func saveUpdatedAndNewArticles(_ articleChanges: Set<NSDictionary>, newArticles: Set<LocalArticle>) {
if articleChanges.isEmpty && newArticles.isEmpty {
return
}
statusesManager.assertNoMissingStatuses(newArticles)
articleCache.cacheArticles(newArticles)
let newArticleDictionaries = newArticles.map { (oneArticle) in
return oneArticle.databaseDictionary
}
queue.update { (database: FMDatabase!) -> Void in
if !articleChanges.isEmpty {
for oneDictionary in articleChanges {
let oneArticleDictionary = oneDictionary.mutableCopy() as! NSMutableDictionary
let articleID = oneArticleDictionary[articleIDKey]!
oneArticleDictionary.removeObject(forKey: articleIDKey)
let _ = database.rs_updateRows(with: oneArticleDictionary as [NSObject: AnyObject], whereKey: articleIDKey, equalsValue: articleID, tableName: articlesTableName)
}
}
if !newArticleDictionaries.isEmpty {
for oneNewArticleDictionary in newArticleDictionaries {
let _ = database.rs_insertRow(with: oneNewArticleDictionary as [NSObject: AnyObject], insertType: RSDatabaseInsertOrReplace, tableName: articlesTableName)
}
}
}
}
// MARK: Updating Articles
func updateArticles(_ articles: [String: LocalArticle], parsedArticles: [String: RSParsedArticle], feed: LocalFeed, completionHandler: @escaping RSVoidCompletionBlock) {
statusesManager.ensureStatusesForParsedArticles(Set(parsedArticles.values)) {
let articleChanges = self.updateExistingArticles(articles, parsedArticles)
let newArticles = self.createNewArticles(articles, parsedArticles: parsedArticles, feedID: feed.feedID)
self.saveUpdatedAndNewArticles(articleChanges, newArticles: newArticles)
completionHandler()
}
}
func articlesDictionary(_ articles: NSSet) -> [String: AnyObject] {
var d = [String: AnyObject]()
for oneArticle in articles {
let oneArticleID = (oneArticle as AnyObject).value(forKey: articleIDKey) as! String
d[oneArticleID] = oneArticle as AnyObject
}
return d
}
func updateExistingArticles(_ articles: [String: LocalArticle], _ parsedArticles: [String: RSParsedArticle]) -> Set<NSDictionary> {
var articleChanges = Set<NSDictionary>()
for oneArticle in articles.values {
if let oneParsedArticle = parsedArticles[oneArticle.articleID] {
if let oneArticleChanges = oneArticle.updateWithParsedArticle(oneParsedArticle) {
articleChanges.insert(oneArticleChanges)
}
}
}
return articleChanges
}
// MARK: Creating Articles
func createNewArticlesWithParsedArticles(_ parsedArticles: Set<RSParsedArticle>, feedID: String) -> Set<LocalArticle> {
return Set(parsedArticles.map { LocalArticle(account: account, feedID: feedID, parsedArticle: $0) })
}
func articlesWithParsedArticles(_ parsedArticles: Set<RSParsedArticle>, feedID: String) -> Set<LocalArticle> {
var localArticles = Set<LocalArticle>()
for oneParsedArticle in parsedArticles {
let oneLocalArticle = LocalArticle(account: self.account, feedID: feedID, parsedArticle: oneParsedArticle)
localArticles.insert(oneLocalArticle)
}
return localArticles
}
func createNewArticles(_ existingArticles: [String: LocalArticle], parsedArticles: [String: RSParsedArticle], feedID: String) -> Set<LocalArticle> {
let newParsedArticles = parsedArticlesMinusExistingArticles(parsedArticles, existingArticles: existingArticles)
let newArticles = createNewArticlesWithParsedArticles(newParsedArticles, feedID: feedID)
statusesManager.attachCachedUniqueStatuses(newArticles)
return newArticles
}
func parsedArticlesMinusExistingArticles(_ parsedArticles: [String: RSParsedArticle], existingArticles: [String: LocalArticle]) -> Set<RSParsedArticle> {
var result = Set<RSParsedArticle>()
for oneParsedArticle in parsedArticles.values {
if let _ = existingArticles[oneParsedArticle.articleID] {
continue
}
result.insert(oneParsedArticle)
}
return result
}
// MARK: Fetching Articles
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]?) -> Set<LocalArticle> {
let sql = "select * from articles natural join statuses where \(whereClause);"
logSQL(sql)
if let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) {
return articlesWithResultSet(resultSet)
}
return Set<LocalArticle>()
}
func articlesWithResultSet(_ resultSet: FMResultSet) -> Set<LocalArticle> {
var fetchedArticles = Set<LocalArticle>()
while (resultSet.next()) {
if let oneArticle = LocalArticle(account: self.account, row: resultSet) {
oneArticle.status = LocalArticleStatus(row: resultSet)
fetchedArticles.insert(oneArticle)
}
}
return fetchedArticles
}
func fetchArticlesForFeedID(_ feedID: String, database: FMDatabase) -> Set<LocalArticle> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject])
}
// MARK: Unread counts
func numberWithCountResultSet(_ resultSet: FMResultSet?) -> Int {
guard let resultSet = resultSet else {
return 0
}
if resultSet.next() {
return Int(resultSet.int(forColumnIndex: 0))
}
return 0
}
func numberWithSQLAndParameters(_ sql: String, parameters: [Any], _ database: FMDatabase) -> Int {
let resultSet = database.executeQuery(sql, withArgumentsIn: parameters)
return numberWithCountResultSet(resultSet)
}
func numberOfArticles(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles where feedID = ?;"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID], database)
}
func unreadCount(_ feedID: String, _ database: FMDatabase) -> Int {
let totalNumberOfArticles = numberOfArticles(feedID, database)
if totalNumberOfArticles <= minimumNumberOfArticles {
return unreadCountIgnoringCutoffDate(feedID, database)
}
return unreadCountRespectingCutoffDate(feedID, database)
}
func unreadCountIgnoringCutoffDate(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0;"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID], database)
}
func unreadCountRespectingCutoffDate(_ feedID: String, _ database: FMDatabase) -> Int {
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);"
logSQL(sql)
return numberWithSQLAndParameters(sql, parameters: [feedID, articleArrivalCutoffDate], database)
}
// MARK: Filtering out old articles
func articleIsOlderThanCutoffDate(_ article: LocalArticle) -> Bool {
if let dateArrived = article.status?.dateArrived {
return dateArrived < articleArrivalCutoffDate
}
return false
}
func articleShouldBeSavedForever(_ article: LocalArticle) -> Bool {
return article.status.starred
}
func articleShouldAppearToUser(_ article: LocalArticle, _ numberOfArticlesInFeed: Int) -> Bool {
if numberOfArticlesInFeed <= minimumNumberOfArticles {
return true
}
return articleShouldBeSavedForever(article) || !articleIsOlderThanCutoffDate(article)
}
private static let minimumNumberOfArticlesInFeed = 10
func filteredArticles(_ articles: Set<LocalArticle>, feedCounts: [String: Int]) -> Set<LocalArticle> {
var articlesSet = Set<LocalArticle>()
for oneArticle in articles {
if let feedCount = feedCounts[oneArticle.feedID], articleShouldAppearToUser(oneArticle, feedCount) {
articlesSet.insert(oneArticle)
}
}
return articlesSet
}
typealias FeedCountCallback = (Int) -> Void
func feedIDsFromArticles(_ articles: Set<LocalArticle>) -> Set<String> {
return Set(articles.map { $0.feedID })
}
func deletePossibleOldArticles(_ articles: Set<LocalArticle>) {
let feedIDs = feedIDsFromArticles(articles)
if feedIDs.isEmpty {
return
}
}
func numberOfArticlesInFeedID(_ feedID: String, callback: @escaping FeedCountCallback) {
queue.fetch { (database: FMDatabase!) -> Void in
let sql = "select count(*) from articles where feedID = ?;"
logSQL(sql)
var numberOfArticles = -1
if let resultSet = database.executeQuery(sql, withArgumentsIn: [feedID]) {
while (resultSet.next()) {
numberOfArticles = resultSet.long(forColumnIndex: 0)
break
}
}
DispatchQueue.main.async() {
callback(numberOfArticles)
}
}
}
func deleteOldArticlesInFeed(_ feed: LocalFeed) {
numberOfArticlesInFeedID(feed.feedID) { (numberOfArticlesInFeed) in
if numberOfArticlesInFeed <= LocalDatabase.minimumNumberOfArticlesInFeed {
return
}
}
}
// MARK: Deleting Articles
// func deleteOldArticles(_ articleIDsInFeeds: Set<String>) {
//
// queue.update { (database: FMDatabase!) -> Void in
//
//// let cutoffDate = NSDate.rs_dateWithNumberOfDaysInThePast(60)
//// let articles = self.fetchArticlesWithWhereClause(database, whereClause: "statuses.dateArrived < ? limit 200", parameters: [cutoffDate])
//
//// var articleIDsToDelete = Set<String>()
////
//// for oneArticle in articles {
// // TODO
//// if !localAccountShouldIncludeArticle(oneArticle, articleIDsInFeed: articleIDsInFeeds) {
//// articleIDsToDelete.insert(oneArticle.articleID)
//// }
//// }
//
//// if !articleIDsToDelete.isEmpty {
//// database.rs_deleteRowsWhereKey(articleIDKey, inValues: Array(articleIDsToDelete), tableName: articlesTableName)
//// }
// }
// }
}

View File

@ -0,0 +1,172 @@
//
// LocalFeed.swift
// Rainier
//
// Created by Brent Simmons on 4/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSWeb
import DataModel
import RSCore
public final class LocalFeed: Feed, PlistProvider, Hashable {
public let account: Account
public let url: String
public let feedID: String
public var homePageURL: String?
var username: String?
public var name: String?
public var editedName: String?
public var contentHash: String?
public var hashValue: Int
private var localAccount: LocalAccount {
get {
return account as! LocalAccount
}
}
public var conditionalGetInfo: HTTPConditionalGetInfo?
public var unreadCount = 0 {
didSet {
postUnreadCountDidChangeNotification()
}
}
public var nameForDisplay: String {
get {
if let name = editedName {
return name
}
if let name = name {
return name
}
return NSLocalizedString("Untitled", comment: "Feed with no name")
}
}
public var plist: AnyObject? {
get {
return createDiskDictionary()
}
}
public init(account: Account, url: String, feedID: String) {
self.account = account
self.url = url
self.feedID = feedID
self.hashValue = feedID.hashValue
}
// MARK: UnreadCountProvider
public func updateUnreadCount() {
(account as! LocalAccount).updateUnreadCountForFeed(self)
}
func addToUnreadCount(amount: Int) {
unreadCount = max(unreadCount + amount, 0)
}
}
public func ==(lhs: LocalFeed, rhs: LocalFeed) -> Bool {
if lhs === rhs {
return true
}
return lhs.hashValue == rhs.hashValue && lhs.account === rhs.account && lhs.url == rhs.url && lhs.feedID == rhs.feedID && lhs.homePageURL == rhs.homePageURL && lhs.name == rhs.name && lhs.editedName == rhs.editedName
}
// MARK: Disk dictionary
let feedIDKey = "feedID"
let feedURLKey = "url"
private let feedHomePageKey = "home"
private let feedNameKey = "name"
private let feedEditedNameKey = "editedName"
private let feedUsernameKey = "username"
private let feedArticleIDsKey = "articleIDs"
private let feedConditionalGetInfoKey = "conditionalGetInfo"
private let feedContentHashKey = "contentHash"
public extension LocalFeed {
public convenience init?(account: Account, diskDictionary: NSDictionary) {
guard let feedURL = diskDictionary[feedURLKey] as? String else {
return nil
}
let feedID: String // If not present, its same as the feed URL.
if let tempFeedID = diskDictionary[feedIDKey] as? String {
feedID = tempFeedID
}
else {
feedID = feedURL
}
self.init(account: account, url: feedURL, feedID: feedID)
if let homePageURL = diskDictionary[feedHomePageKey] as? String {
self.homePageURL = homePageURL
}
if let name = diskDictionary[feedNameKey] as? String {
self.name = name
}
if let editedName = diskDictionary[feedEditedNameKey] as? String {
self.editedName = editedName
}
if let username = diskDictionary[feedUsernameKey] as? String {
self.username = username
}
if let unreadCount = diskDictionary[unreadCountKey] as? Int {
self.unreadCount = unreadCount
}
if let conditionalGetInfoPlist = diskDictionary[feedConditionalGetInfoKey] as? NSDictionary {
if conditionalGetInfoPlist.count > 0 {
self.conditionalGetInfo = HTTPConditionalGetInfo(plist: conditionalGetInfoPlist)
}
}
if let contentHash = diskDictionary[feedContentHashKey] as? String {
self.contentHash = contentHash
}
}
fileprivate func createDiskDictionary() -> NSDictionary {
let d = NSMutableDictionary()
d.setObjectWithStringKey(url as NSString, feedURLKey)
if feedID != url {
d.setObjectWithStringKey(feedID as NSString, feedIDKey)
}
if unreadCount > 0 {
d.setObjectWithStringKey(NSNumber(value: unreadCount), unreadCountKey)
}
d.setOptionalStringValue(homePageURL, feedHomePageKey)
d.setOptionalStringValue(name, feedNameKey)
d.setOptionalStringValue(editedName, feedEditedNameKey)
d.setOptionalStringValue(username, feedUsernameKey)
d.setOptionalStringValue(contentHash, feedContentHashKey)
if let conditionalGetInfoPlist = conditionalGetInfo?.plist as? NSDictionary {
d.setObjectWithStringKey(conditionalGetInfoPlist, feedConditionalGetInfoKey)
}
return d
}
}

View File

@ -0,0 +1,240 @@
//
// LocalFolder.swift
// Rainier
//
// Created by Brent Simmons on 4/23/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import DataModel
let folderIDKey = "folderID"
private let folderNameKey = "name"
// LocalFolders can contain LocalFeeds only. Sub-folders are not allowed.
public final class LocalFolder: Folder, PlistProvider {
public var nameForDisplay: String
public let account: Account?
public var feeds = [String: LocalFeed]()
let folderID: String
public var unreadCount = 0 {
didSet {
postUnreadCountDidChangeNotification()
}
}
public var plist: AnyObject? {
get {
return createDiskDictionary()
}
}
init(nameForDisplay: String, folderID: String, account: Account) {
self.nameForDisplay = nameForDisplay
self.folderID = folderID
self.account = account
}
convenience init(nameForDisplay: String, account: Account) {
self.init(nameForDisplay: nameForDisplay, folderID: uniqueIdentifier(), account: account)
}
// MARK: Folder
public var hasAtLeastOneFeed: Bool {
get {
return !feeds.isEmpty
}
}
public var flattenedFeeds: NSSet {
get {
return Set(feeds.values) as NSSet
}
}
public var flattenedFeedIDs: Set<String> {
get {
return Set(feeds.keys)
}
}
public func fetchArticles() -> [Article] {
if let account = account as? LocalAccount {
let articlesSet = account.fetchArticlesForFolder(self)
return articlesSet.map { $0 as Article }
}
return [Article]()
}
public func objectIsChild(_ obj: AnyObject) -> Bool {
if let feed = obj as? LocalFeed {
return feeds[feed.feedID] != nil
}
return false
}
public func objectIsDescendant(_ obj: AnyObject) -> Bool {
return objectIsChild(obj)
}
public func visitObjects(_ recurse: Bool, visitBlock: FolderVisitBlock) -> Bool {
for oneFeed in feeds.values {
if visitBlock(oneFeed) {
return true
}
}
return false
}
public func existingFeedWithID(_ feedID: String) -> Feed? {
return feeds[feedID] as Feed?
}
public func existingFeedWithURL(_ urlString: String) -> Feed? {
return feeds[urlString] as Feed?
}
public func existingFolderWithName(_ name: String) -> Folder? {
return nil
}
public func canAddItem(_ item: AnyObject) -> Bool {
return item is LocalFeed
}
public func addItem(_ item: AnyObject) -> Bool {
guard let feed = item as? LocalFeed else {
return false
}
if let _ = existingFeedWithID(feed.feedID) {
return true
}
feeds[feed.feedID] = feed
FolderPostChildrenDidChangeNotification(self)
return true
}
public func canAddFolderWithName(_ folderName: String) -> Bool {
return false
}
public func ensureFolderWithName(_ folderName: String) -> Folder? {
return nil
}
public func createFeedWithName(_ name: String?, editedName: String?, urlString: String) -> Feed? {
return account?.createFeedWithName(name, editedName: editedName, urlString: urlString)
}
public func deleteItems(_ items: [AnyObject]) {
deleteFeeds(feedsWithItems(items))
FolderPostChildrenDidChangeNotification(self)
updateUnreadCount()
}
// MARK: UnreadCountProvider
public func updateUnreadCount() {
let updatedUnreadCount = calculateUnreadCount(feeds.values)
if updatedUnreadCount != unreadCount {
unreadCount = updatedUnreadCount
}
}
}
// MARK: Disk
extension LocalFolder {
convenience init?(account: LocalAccount, diskDictionary: NSDictionary) {
guard let folderID = diskDictionary[folderIDKey] as? String else {
return nil
}
guard let folderName = diskDictionary[folderNameKey] as? String else {
return nil
}
self.init(nameForDisplay: folderName, folderID: folderID, account: account as Account)
if let childrenDiskArray = diskDictionary[diskDictionaryChildrenKey] as? NSArray {
let childrenArray = account.childrenForDiskArray(childrenDiskArray)
childrenArray.forEach{ (oneItem) in
if let oneFeed = oneItem as? LocalFeed {
feeds[oneFeed.feedID] = oneFeed
}
}
}
}
}
// MARK: Private
private extension LocalFolder {
func createDiskDictionary() -> NSDictionary {
let d = NSMutableDictionary()
d.setObjectWithStringKey(folderID as NSString, folderIDKey)
d.setObjectWithStringKey(nameForDisplay as NSString, folderNameKey)
if unreadCount > 0 {
d.setObjectWithStringKey(NSNumber(value: unreadCount), unreadCountKey)
}
let children = NSMutableArray()
feeds.values.forEach { (oneLocalFeed) in
if let onePlist = oneLocalFeed.plist {
children.add(onePlist)
}
}
if children.count > 0 {
d.setObjectWithStringKey(children, diskDictionaryChildrenKey)
}
return d
}
func feedsWithItems(_ items: [AnyObject]) -> [LocalFeed] {
return items.flatMap { $0 as? LocalFeed }
}
func deleteFeeds(_ feeds: [LocalFeed]) {
feeds.forEach { deleteFeed($0) }
}
func deleteFeed(_ feed: LocalFeed) {
feeds[feed.feedID] = nil
}
}

View File

@ -0,0 +1,211 @@
//
// LocalStatusesManager.swift
// Rainier
//
// Created by Brent Simmons on 5/8/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSXML
import DataModel
final class LocalStatusesManager {
var cachedStatuses = [String: LocalArticleStatus]()
let queue: RSDatabaseQueue
init(queue: RSDatabaseQueue) {
self.queue = queue
}
func markArticles(_ articles: Set<LocalArticle>, statusKey: ArticleStatusKey, flag: Bool) {
assertNoMissingStatuses(articles)
let statusArray = articles.map { $0.status! as! LocalArticleStatus }
let statuses = Set(statusArray)
markArticleStatuses(statuses, statusKey: statusKey, flag: flag)
}
func attachCachedUniqueStatuses(_ articles: Set<LocalArticle>) {
articles.forEach { (oneLocalArticle) in
if let cachedStatus = cachedStatusForArticleID(oneLocalArticle.articleID) {
oneLocalArticle.status = cachedStatus
}
else if let oneLocalArticleStatus = oneLocalArticle.status as? LocalArticleStatus {
cacheStatus(oneLocalArticleStatus)
}
}
}
func ensureStatusesForParsedArticles(_ parsedArticles: Set<RSParsedArticle>, _ callback: @escaping RSVoidCompletionBlock) {
var articleIDs = Set(parsedArticles.map { $0.articleID })
articleIDs = articleIDsMissingStatuses(articleIDs)
if articleIDs.isEmpty {
callback()
return
}
queue.fetch { (database: FMDatabase!) -> Void in
let statuses = self.fetchStatusesForArticleIDs(articleIDs, database: database)
DispatchQueue.main.async { () -> Void in
self.cacheStatuses(statuses)
let newArticleIDs = self.articleIDsMissingStatuses(articleIDs)
self.createStatusForNewArticleIDs(newArticleIDs)
callback()
}
}
}
func assertNoMissingStatuses(_ articles: Set<LocalArticle>) {
for oneArticle in articles {
if oneArticle.status == nil {
assertionFailure("All articles must have a status at this point.")
return
}
}
}
}
// MARK: - Private
private let statusesTableName = "statuses"
private extension LocalStatusesManager {
// MARK: Marking
func markArticleStatuses(_ statuses: Set<LocalArticleStatus>, statusKey: ArticleStatusKey, flag: Bool) {
// Ignore the statuses where status.[statusKey] == flag. Update the remainder and save in database.
var articleIDs = Set<String>()
statuses.forEach { (oneStatus) in
if oneStatus.boolStatusForKey(statusKey) != flag {
oneStatus.setBoolStatusForKey(flag, articleStatusKey: statusKey)
articleIDs.insert(oneStatus.articleID)
}
}
if !articleIDs.isEmpty {
updateArticleStatusesInDatabase(articleIDs, statusKey: statusKey, flag: flag)
}
}
// MARK: Fetching
func fetchStatusesForArticleIDs(_ articleIDs: Set<String>, database: FMDatabase) -> Set<LocalArticleStatus> {
guard !articleIDs.isEmpty else {
return Set<LocalArticleStatus>()
}
guard let resultSet = database.rs_selectRowsWhereKey(articleIDKey, inValues: Array(articleIDs), tableName: statusesTableName) else {
return Set<LocalArticleStatus>()
}
return localArticleStatusesWithResultSet(resultSet)
}
func localArticleStatusesWithResultSet(_ resultSet: FMResultSet) -> Set<LocalArticleStatus> {
var statuses = Set<LocalArticleStatus>()
while(resultSet.next()) {
if let oneArticleStatus = LocalArticleStatus(row: resultSet) {
statuses.insert(oneArticleStatus)
}
}
return statuses
}
// MARK: Saving
func saveStatuses(_ statuses: Set<LocalArticleStatus>) {
let statusArray = statuses.map { (oneStatus) -> NSDictionary in
return oneStatus.databaseDictionary
}
queue.update { (database: FMDatabase!) -> Void in
statusArray.forEach { (oneStatusDictionary) in
let _ = database.rs_insertRow(with: oneStatusDictionary as [NSObject: AnyObject], insertType: RSDatabaseInsertOrIgnore, tableName: "statuses")
}
}
}
private func updateArticleStatusesInDatabase(_ articleIDs: Set<String>, statusKey: ArticleStatusKey, flag: Bool) {
queue.update { (database: FMDatabase!) -> Void in
let _ = database.rs_updateRows(withValue: NSNumber(value: flag), valueKey: statusKey.rawValue, whereKey: articleIDKey, inValues: Array(articleIDs), tableName: statusesTableName)
}
}
// MARK: Creating
func createStatusForNewArticleIDs(_ articleIDs: Set<String>) {
let now = Date()
let statuses = articleIDs.map { (oneArticleID) -> LocalArticleStatus in
return LocalArticleStatus(articleID: oneArticleID, read: false, starred: false, userDeleted: false, dateArrived: now)
}
cacheStatuses(Set(statuses))
queue.update { (database: FMDatabase!) -> Void in
let falseValue = NSNumber(value: false)
articleIDs.forEach { (oneArticleID) in
let _ = database.executeUpdate("insert or ignore into statuses (read, articleID, starred, userDeleted, dateArrived) values (?, ?, ?, ?, ?)", withArgumentsIn:[falseValue, oneArticleID as NSString, falseValue, falseValue, now])
}
}
}
// MARK: Cache
func cachedStatusForArticleID(_ articleID: String) -> LocalArticleStatus? {
return cachedStatuses[articleID]
}
func cacheStatus(_ status: LocalArticleStatus) {
cacheStatuses(Set([status]))
}
func cacheStatuses(_ statuses: Set<LocalArticleStatus>) {
statuses.forEach { (oneStatus) in
if let _ = cachedStatuses[oneStatus.articleID] {
return
}
cachedStatuses[oneStatus.articleID] = oneStatus
}
}
// MARK: Utilities
func articleIDsMissingStatuses(_ articleIDs: Set<String>) -> Set<String> {
return Set(articleIDs.filter { cachedStatusForArticleID($0) == nil })
}
}