Add LocalAccount framework. Note: build is broken.
This commit is contained in:
parent
91d81831e9
commit
e4d1ed8bd9
@ -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 */;
|
||||
|
15
Frameworks/LocalAccount/DiskDictionaryConstants.swift
Normal file
15
Frameworks/LocalAccount/DiskDictionaryConstants.swift
Normal 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"
|
||||
|
26
Frameworks/LocalAccount/Info.plist
Normal file
26
Frameworks/LocalAccount/Info.plist
Normal 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>
|
654
Frameworks/LocalAccount/LocalAccount.swift
Normal file
654
Frameworks/LocalAccount/LocalAccount.swift
Normal 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]() // Shouldn’t 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]) {
|
||||
|
||||
// FeedBin’s 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
482
Frameworks/LocalAccount/LocalAccount.xcodeproj/project.pbxproj
Normal file
482
Frameworks/LocalAccount/LocalAccount.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
7
Frameworks/LocalAccount/LocalAccount.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Frameworks/LocalAccount/LocalAccount.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:LocalAccount.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
130
Frameworks/LocalAccount/LocalAccountRefresher.swift
Normal file
130
Frameworks/LocalAccount/LocalAccountRefresher.swift
Normal 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).")
|
||||
}
|
||||
|
||||
|
||||
}
|
22
Frameworks/LocalAccount/LocalAccountTests/Info.plist
Normal file
22
Frameworks/LocalAccount/LocalAccountTests/Info.plist
Normal 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>
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
196
Frameworks/LocalAccount/LocalArticle.swift
Normal file
196
Frameworks/LocalAccount/LocalArticle.swift
Normal 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
|
||||
}
|
||||
}
|
94
Frameworks/LocalAccount/LocalArticleCache.swift
Normal file
94
Frameworks/LocalAccount/LocalArticleCache.swift
Normal 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
|
||||
// }
|
||||
//
|
||||
//}
|
119
Frameworks/LocalAccount/LocalArticleStatus.swift
Normal file
119
Frameworks/LocalAccount/LocalArticleStatus.swift
Normal 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
|
||||
}
|
||||
}
|
9
Frameworks/LocalAccount/LocalCreateStatements.sql
Normal file
9
Frameworks/LocalAccount/LocalCreateStatements.sql
Normal 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);
|
553
Frameworks/LocalAccount/LocalDatabase.swift
Normal file
553
Frameworks/LocalAccount/LocalDatabase.swift
Normal 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)
|
||||
//// }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
}
|
172
Frameworks/LocalAccount/LocalFeed.swift
Normal file
172
Frameworks/LocalAccount/LocalFeed.swift
Normal 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, it’s 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
|
||||
}
|
||||
|
||||
}
|
240
Frameworks/LocalAccount/LocalFolder.swift
Normal file
240
Frameworks/LocalAccount/LocalFolder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
211
Frameworks/LocalAccount/LocalStatusesManager.swift
Normal file
211
Frameworks/LocalAccount/LocalStatusesManager.swift
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user