NetNewsWire/Frameworks/Account/Account.swift

790 lines
20 KiB
Swift
Raw Normal View History

//
// Account.swift
// DataModel
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import Articles
2017-09-17 21:08:50 +02:00
import RSParser
import ArticlesDatabase
import RSWeb
import RSDatabase
public extension Notification.Name {
public static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin")
public static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish")
public static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange")
public static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles")
public static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange")
}
public enum AccountType: Int {
// Raw values should not change since theyre stored on disk.
case onMyMac = 1
case feedly = 16
case feedbin = 17
case feedWrangler = 18
case newsBlur = 19
// TODO: more
}
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
public struct UserInfoKey {
public static let newArticles = "newArticles" // AccountDidDownloadArticles
public static let updatedArticles = "updatedArticles" // AccountDidDownloadArticles
public static let statuses = "statuses" // StatusesDidChange
public static let articles = "articles" // StatusesDidChange
public static let feeds = "feeds" // AccountDidDownloadArticles, StatusesDidChange
}
2017-09-17 21:08:50 +02:00
public let accountID: String
public let type: AccountType
2017-09-17 21:08:50 +02:00
public var nameForDisplay = ""
public var children = [AnyObject]()
var urlToFeedDictionary = [String: Feed]()
var idToFeedDictionary = [String: Feed]()
let settingsFile: String
let dataFolder: String
let database: ArticlesDatabase
let delegate: AccountDelegate
var username: String?
static let saveQueue = CoalescingQueue(name: "Account Save Queue", interval: 1.0)
private let settingsODB: ODB
private let settingsTable: ODBTable
private let feedsPath: ODBPath
private let feedsTable: ODBTable
private var unreadCounts = [String: Int]() // [feedID: Int]
2018-09-14 22:25:38 +02:00
private let opmlFilePath: String
private struct SettingsKey {
static let unreadCount = "unreadCount"
}
public var dirty = false {
didSet {
if dirty && !refreshInProgress {
queueSaveToDiskIfNeeded()
}
}
}
public var unreadCount = 0 {
didSet {
if unreadCount != oldValue {
postUnreadCountDidChangeNotification()
}
}
}
var refreshInProgress = false {
didSet {
if refreshInProgress != oldValue {
if refreshInProgress {
NotificationCenter.default.post(name: .AccountRefreshDidBegin, object: self)
}
else {
NotificationCenter.default.post(name: .AccountRefreshDidFinish, object: self)
queueSaveToDiskIfNeeded()
}
}
}
}
2017-09-28 22:16:47 +02:00
var refreshProgress: DownloadProgress {
return delegate.refreshProgress
}
2017-09-28 22:16:47 +02:00
var supportsSubFolders: Bool {
return delegate.supportsSubFolders
2017-09-28 22:16:47 +02:00
}
init?(dataFolder: String, settingsFile: String, type: AccountType, accountID: String) {
// TODO: support various syncing systems.
precondition(type == .onMyMac)
self.delegate = LocalAccountDelegate()
2017-09-18 02:03:58 +02:00
self.accountID = accountID
self.type = type
self.settingsFile = settingsFile
self.dataFolder = dataFolder
2018-09-14 22:25:38 +02:00
self.opmlFilePath = (dataFolder as NSString).appendingPathComponent("Subscriptions.opml")
2017-09-18 02:03:58 +02:00
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3")
self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID)
let settingsODBFilePath = (dataFolder as NSString).appendingPathComponent("Settings.odb")
self.settingsODB = ODB(filepath: settingsODBFilePath)
self.settingsODB.vacuum()
let settingsPath = ODBPath.path(["settings"])
self.settingsTable = settingsODB.ensureTable(settingsPath)!
self.feedsPath = ODBPath.path(["feeds"])
self.feedsTable = settingsODB.ensureTable(self.feedsPath)!
NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
pullObjectsFromDisk()
DispatchQueue.main.async {
self.fetchAllUnreadCounts()
}
self.delegate.accountDidInitialize(self)
}
2017-09-17 20:32:58 +02:00
// MARK: - API
public func refreshAll() {
delegate.refreshAll(for: self)
}
public func update(_ feed: Feed, with parsedFeed: ParsedFeed, _ completion: @escaping RSVoidCompletionBlock) {
feed.takeSettings(from: parsedFeed)
database.update(feedID: feed.feedID, parsedFeed: parsedFeed) { (newArticles, updatedArticles) in
var userInfo = [String: Any]()
if let newArticles = newArticles, !newArticles.isEmpty {
self.updateUnreadCounts(for: Set([feed]))
userInfo[UserInfoKey.newArticles] = newArticles
}
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
userInfo[UserInfoKey.feeds] = Set([feed])
2017-09-17 21:08:50 +02:00
completion()
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
2017-09-17 21:08:50 +02:00
}
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
// Returns set of Articles whose statuses did change.
guard let updatedStatuses = database.mark(articles, statusKey: statusKey, flag: flag) else {
return nil
}
let updatedArticleIDs = updatedStatuses.articleIDs()
let updatedArticles = Set(articles.filter{ updatedArticleIDs.contains($0.articleID) })
noteStatusesForArticlesDidChange(updatedArticles)
return updatedArticles
2017-09-18 01:30:45 +02:00
}
@discardableResult
2017-09-17 22:07:55 +02:00
public func ensureFolder(with name: String) -> Folder? {
// TODO: support subfolders, maybe, some day
if name.isEmpty {
return nil
}
if let folder = existingFolder(with: name) {
return folder
}
let folder = Folder(account: self, name: name)
children += [folder]
dirty = true
2017-10-19 22:27:59 +02:00
postChildrenDidChangeNotification()
return folder
2017-09-17 22:07:55 +02:00
}
public func ensureFolder(withFolderNames folderNames: [String]) -> Folder? {
// TODO: support subfolders, maybe, some day.
// Since we dont, just take the last name and make sure theres a Folder.
guard let folderName = folderNames.last else {
return nil
}
return ensureFolder(with: folderName)
}
public func canAddFeed(_ feed: Feed, to folder: Folder?) -> Bool {
// If folder is nil, then it should go at the top level.
// The same feed in multiple folders is allowed.
// But the same feed cant appear twice in the same folder
// (or at the top level).
return true // TODO
}
2017-10-22 01:32:29 +02:00
@discardableResult
public func addFeed(_ feed: Feed, to folder: Folder?) -> Bool {
// Return false if it couldnt be added.
// If it already existed in that folder, return true.
2017-10-01 19:59:35 +02:00
var didAddFeed = false
let uniquedFeed = existingFeed(with: feed.feedID) ?? feed
if let folder = folder {
didAddFeed = folder.addFeed(uniquedFeed)
}
else {
if !topLevelObjectsContainsFeed(uniquedFeed) {
children += [uniquedFeed]
postChildrenDidChangeNotification()
2017-10-01 19:59:35 +02:00
}
didAddFeed = true
}
if didAddFeed {
addToFeedDictionaries(uniquedFeed)
dirty = true
}
2017-10-01 19:59:35 +02:00
return didAddFeed
}
2017-10-01 20:28:44 +02:00
public func createFeed(with name: String?, editedName: String?, url: String) -> Feed? {
2017-10-01 01:56:48 +02:00
// For syncing, this may need to be an async method with a callback,
// since it will likely need to call the server.
if let feed = existingFeed(withURL: url) {
2017-10-01 20:28:44 +02:00
if let editedName = editedName {
feed.editedName = editedName
}
2017-10-01 01:56:48 +02:00
return feed
}
let feed = Feed(account: self, url: url, feedID: url)
2017-10-01 01:56:48 +02:00
feed.name = name
2017-10-01 19:59:35 +02:00
feed.editedName = editedName
2018-02-20 09:26:46 +01:00
2017-10-01 01:56:48 +02:00
return feed
}
2017-09-27 06:43:40 +02:00
public func canAddFolder(_ folder: Folder, to containingFolder: Folder?) -> Bool {
return false // TODO
}
2017-10-22 06:00:21 +02:00
@discardableResult
public func addFolder(_ folder: Folder, to parentFolder: Folder?) -> Bool {
2017-11-05 21:14:36 +01:00
// TODO: support subfolders, maybe, some day, if one of the sync systems
// supports subfolders. But, for now, parentFolder is ignored.
if objectIsChild(folder) {
return true
}
children += [folder]
postChildrenDidChangeNotification()
rebuildFeedDictionaries()
2017-11-05 21:14:36 +01:00
return true
}
public func importOPML(_ opmlDocument: RSOPMLDocument) {
guard let children = opmlDocument.children else {
return
}
rebuildFeedDictionaries()
2017-10-22 06:00:21 +02:00
importOPMLItems(children, parentFolder: nil)
saveToDisk()
DispatchQueue.main.async {
self.refreshAll()
}
}
public func updateUnreadCounts(for feeds: Set<Feed>) {
2017-10-19 06:53:45 +02:00
if feeds.isEmpty {
return
}
database.fetchUnreadCounts(for: feeds.feedIDs()) { (unreadCountDictionary) in
for feed in feeds {
if let unreadCount = unreadCountDictionary[feed.feedID] {
feed.unreadCount = unreadCount
}
}
}
}
public func fetchArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(for: feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles(for feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(for: Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
public func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
public func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
public func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(for: feeds.feedIDs())
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
articles.forEach { (article) in
precondition(!article.status.read)
unreadCountStorage[article.feedID, default: 0] += 1
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
}
return articles
}
public func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(for: flattenedFeeds().feedIDs())
}
public func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(for: flattenedFeeds().feedIDs())
}
private func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
}
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
let startOfToday = NSCalendar.startOfToday()
database.fetchUnreadCount(for: flattenedFeeds().feedIDs(), since: startOfToday, callback: callback)
}
public func fetchUnreadCountForStarredArticles(_ callback: @escaping (Int) -> Void) {
database.fetchStarredAndUnreadCount(for: flattenedFeeds().feedIDs(), callback: callback)
}
2017-11-20 01:28:26 +01:00
public func markEverywhereAsRead() {
// Does not support undo.
database.markEverywhereAsRead()
flattenedFeeds().forEach { $0.unreadCount = 0 }
}
2018-09-14 22:25:38 +02:00
public func opmlDocument() -> String {
let escapedTitle = nameForDisplay.rs_stringByEscapingSpecialXMLCharacters()
let openingText =
"""
<?xml version="1.0" encoding="UTF-8"?>
<!-- OPML generated by NetNewsWire -->
<opml version="1.1">
<head>
<title>\(escapedTitle)</title>
</head>
<body>
"""
let middleText = OPMLString(indentLevel: 0)
let closingText =
"""
</body>
</opml>
"""
let opml = openingText + middleText + closingText
return opml
}
public func unreadCount(for feed: Feed) -> Int {
return unreadCounts[feed.feedID] ?? 0
}
public func setUnreadCount(_ unreadCount: Int, for feed: Feed) {
unreadCounts[feed.feedID] = unreadCount
}
// MARK: - Debug
public func debugDropConditionalGetInfo() {
#if DEBUG
flattenedFeeds().forEach{ $0.debugDropConditionalGetInfo() }
#endif
}
// MARK: - Notifications
@objc func downloadProgressDidChange(_ note: Notification) {
guard let noteObject = note.object as? DownloadProgress, noteObject === refreshProgress else {
return
}
refreshInProgress = refreshProgress.numberRemaining > 0
NotificationCenter.default.post(name: .AccountRefreshProgressDidChange, object: self)
}
@objc func unreadCountDidChange(_ note: Notification) {
// Update the unread count if its a direct child.
if let object = note.object, objectIsChild(object as AnyObject) {
updateUnreadCount()
}
}
@objc func batchUpdateDidPerform(_ note: Notification) {
rebuildFeedDictionaries()
updateUnreadCount()
}
@objc func feedSettingDidChange(_ note: Notification) {
if let feed = note.object as? Feed, feed.account === self {
dirty = true
}
}
@objc func childrenDidChange(_ note: Notification) {
guard let object = note.object else {
return
}
if let account = object as? Account, account === self {
dirty = true
}
if let folder = object as? Folder, folder.account === self {
dirty = true
}
}
@objc func displayNameDidChange(_ note: Notification) {
if let feed = note.object as? Feed, feed.account === self {
dirty = true
}
if let folder = note.object as? Folder, folder.account === self {
dirty = true
}
}
@objc func saveToDiskIfNeeded() {
if dirty {
saveToDisk()
}
}
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(accountID)
}
2017-09-17 20:32:58 +02:00
// MARK: - Equatable
2017-09-17 20:32:58 +02:00
public class func ==(lhs: Account, rhs: Account) -> Bool {
2017-09-17 20:32:58 +02:00
return lhs === rhs
}
}
// MARK: - Disk (Public)
extension Account {
func objects(with diskObjects: [[String: Any]]) -> [AnyObject] {
return diskObjects.compactMap { object(with: $0) }
}
func settingsTableForFeed(feedID: String) -> ODBRawValueTable? {
let feedPath = feedsPath + feedID
let table = settingsODB.ensureTable(feedPath)
return table?.rawValueTable
}
}
// MARK: - Disk (Private)
private extension Account {
struct Key {
static let children = "children"
static let userInfo = "userInfo"
static let unreadCount = "unreadCount"
}
func queueSaveToDiskIfNeeded() {
Account.saveQueue.add(self, #selector(saveToDiskIfNeeded))
}
2017-09-28 22:16:47 +02:00
func object(with diskObject: [String: Any]) -> AnyObject? {
2017-09-28 22:16:47 +02:00
if Feed.isFeedDictionary(diskObject) {
return Feed(account: self, dictionary: diskObject)
}
2017-09-28 22:16:47 +02:00
return Folder(account: self, dictionary: diskObject)
}
func pullObjectsFromDisk() {
let settingsFileURL = URL(fileURLWithPath: settingsFile)
guard let d = NSDictionary(contentsOf: settingsFileURL) as? [String: Any] else {
return
}
guard let childrenArray = d[Key.children] as? [[String: Any]] else {
return
}
children = objects(with: childrenArray)
rebuildFeedDictionaries()
let userInfo = d[Key.userInfo] as? NSDictionary
delegate.update(account: self, withUserInfo: userInfo)
}
func diskDictionary() -> NSDictionary {
let diskObjects = children.compactMap { (object) -> [String: Any]? in
if let folder = object as? Folder {
return folder.dictionary
}
else if let feed = object as? Feed {
return feed.dictionary
}
return nil
}
var d = [String: Any]()
d[Key.children] = diskObjects as NSArray
if let userInfo = delegate.userInfo(for: self) {
d[Key.userInfo] = userInfo
}
return d as NSDictionary
}
func saveToDisk() {
2018-09-14 22:25:38 +02:00
dirty = false
let d = diskDictionary()
do {
try RSPlist.write(d, filePath: settingsFile)
}
catch let error as NSError {
NSApplication.shared.presentError(error)
}
2018-09-14 22:25:38 +02:00
let opmlDocumentString = opmlDocument()
do {
let url = URL(fileURLWithPath: opmlFilePath)
try opmlDocumentString.write(to: url, atomically: true, encoding: .utf8)
}
catch let error as NSError {
NSApplication.shared.presentError(error)
}
}
}
2017-10-01 19:59:35 +02:00
// MARK: - Private
private extension Account {
func rebuildFeedDictionaries() {
var urlDictionary = [String: Feed]()
var idDictionary = [String: Feed]()
flattenedFeeds().forEach { (feed) in
urlDictionary[feed.url] = feed
idDictionary[feed.feedID] = feed
}
urlToFeedDictionary = urlDictionary
idToFeedDictionary = idDictionary
}
func addToFeedDictionaries(_ feed: Feed) {
urlToFeedDictionary[feed.url] = feed
idToFeedDictionary[feed.feedID] = feed
}
2017-10-01 19:59:35 +02:00
func topLevelObjectsContainsFeed(_ feed: Feed) -> Bool {
return children.contains(where: { (object) -> Bool in
2017-10-01 19:59:35 +02:00
if let oneFeed = object as? Feed {
if oneFeed.feedID == feed.feedID {
return true
}
}
return false
})
}
2017-10-22 06:00:21 +02:00
func createFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> Feed {
let feed = Feed(account: self, url: opmlFeedSpecifier.feedURL, feedID: opmlFeedSpecifier.feedURL)
2017-10-22 06:00:21 +02:00
feed.editedName = opmlFeedSpecifier.title
return feed
}
func importOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
2017-10-22 06:00:21 +02:00
items.forEach { (item) in
if let feedSpecifier = item.feedSpecifier {
2017-10-22 06:00:21 +02:00
let feed = createFeed(with: feedSpecifier)
addFeed(feed, to: parentFolder)
return
}
2017-10-22 06:00:21 +02:00
guard item.isFolder, let itemChildren = item.children else {
return
}
2017-10-22 06:00:21 +02:00
// TODO: possibly support sub folders.
2017-10-22 06:00:21 +02:00
guard let folderName = item.titleFromAttributes else {
// Folder doesnt have a name, so it wont be created, and its items will go one level up.
importOPMLItems(itemChildren, parentFolder: parentFolder)
return
}
2017-10-22 06:00:21 +02:00
if let folder = ensureFolder(with: folderName) {
importOPMLItems(itemChildren, parentFolder: folder)
}
}
}
func updateUnreadCount() {
unreadCount = calculateUnreadCount(flattenedFeeds())
}
func noteStatusesForArticlesDidChange(_ articles: Set<Article>) {
let feeds = Set(articles.compactMap { $0.feed })
let statuses = Set(articles.map { $0.status })
// .UnreadCountDidChange notification will get sent to Folder and Account objects,
// which will update their own unread counts.
updateUnreadCounts(for: feeds)
NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.statuses: statuses, UserInfoKey.articles: articles, UserInfoKey.feeds: feeds])
}
func fetchAllUnreadCounts() {
database.fetchAllNonZeroUnreadCounts { (unreadCountDictionary) in
if unreadCountDictionary.isEmpty {
return
}
self.flattenedFeeds().forEach{ (feed) in
// When the unread count is zero, it wont appear in unreadCountDictionary.
if let unreadCount = unreadCountDictionary[feed.feedID] {
feed.unreadCount = unreadCount
}
else {
feed.unreadCount = 0
}
}
self.updateUnreadCount()
}
}
}
// MARK: - Container Overrides
extension Account {
public func existingFeed(withURL url: String) -> Feed? {
return urlToFeedDictionary[url]
}
public func existingFeed(with feedID: String) -> Feed? {
return idToFeedDictionary[feedID]
}
}
// MARK: - OPMLRepresentable
extension Account: OPMLRepresentable {
public func OPMLString(indentLevel: Int) -> String {
var s = ""
for oneObject in children {
if let oneOPMLObject = oneObject as? OPMLRepresentable {
s += oneOPMLObject.OPMLString(indentLevel: indentLevel + 1)
}
}
return s
}
}