NetNewsWire/Frameworks/Account/Account.swift

1214 lines
34 KiB
Swift
Raw Normal View History

//
// Account.swift
2019-07-09 08:06:40 +02:00
// NetNewsWire
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
#if os(iOS)
import UIKit
#endif
import Foundation
import RSCore
import Articles
2017-09-17 21:08:50 +02:00
import RSParser
import ArticlesDatabase
import RSWeb
import os.log
// Main thread only.
public extension Notification.Name {
static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin")
static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish")
static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange")
static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles")
2019-05-02 12:41:44 +02:00
static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange")
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
2019-06-20 14:22:51 +02:00
case freshRSS = 20
// TODO: more
}
public enum FetchType {
case starred
case unread
case today
case unreadForFolder(Folder)
case feed(Feed)
case articleIDs(Set<String>)
case search(String)
}
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
public struct UserInfoKey {
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
}
public static let defaultLocalAccountName: String = {
let defaultName: String
#if os(macOS)
defaultName = NSLocalizedString("On My Mac", comment: "Account name")
#else
if UIDevice.current.userInterfaceIdiom == .pad {
defaultName = NSLocalizedString("On My iPad", comment: "Account name")
} else {
defaultName = NSLocalizedString("On My iPhone", comment: "Account name")
}
#endif
return defaultName
}()
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "account")
public var isDeleted = false
public var account: Account? {
return self
}
2017-09-17 21:08:50 +02:00
public let accountID: String
public let type: AccountType
public var nameForDisplay: String {
guard let name = name, !name.isEmpty else {
return defaultName
}
return name
}
public var name: String? {
get {
return metadata.name
}
set {
let currentNameForDisplay = nameForDisplay
if newValue != metadata.name {
metadata.name = newValue
if currentNameForDisplay != nameForDisplay {
postDisplayNameDidChangeNotification()
}
}
}
}
public let defaultName: String
2019-05-02 12:41:44 +02:00
public var isActive: Bool {
get {
return metadata.isActive
2019-05-02 12:41:44 +02:00
}
set {
if newValue != metadata.isActive {
metadata.isActive = newValue
2019-05-02 12:41:44 +02:00
NotificationCenter.default.post(name: .AccountStateDidChange, object: self, userInfo: nil)
}
}
}
public var topLevelFeeds = Set<Feed>()
public var folders: Set<Folder>? = Set<Folder>()
private var feedDictionaryNeedsUpdate = true
private var _idToFeedDictionary = [String: Feed]()
var idToFeedDictionary: [String: Feed] {
if feedDictionaryNeedsUpdate {
rebuildFeedDictionaries()
}
return _idToFeedDictionary
}
var username: String? {
get {
return metadata.username
}
set {
if newValue != metadata.username {
metadata.username = newValue
}
}
}
public var endpointURL: URL? {
get {
return metadata.endpointURL
}
set {
if newValue != metadata.endpointURL {
metadata.endpointURL = newValue
}
}
}
private var fetchingAllUnreadCounts = false
var isUnreadCountsInitialized = false
let dataFolder: String
let database: ArticlesDatabase
2019-05-05 10:25:21 +02:00
var delegate: AccountDelegate
static let saveQueue = CoalescingQueue(name: "Account Save Queue", interval: 1.0)
private var unreadCounts = [String: Int]() // [feedID: Int]
2018-09-14 22:25:38 +02:00
private let opmlFilePath: String
private var _flattenedFeeds = Set<Feed>()
private var flattenedFeedsNeedUpdate = true
private let metadataPath: String
var metadata = AccountMetadata()
private var metadataDirty = false {
didSet {
queueSaveAccountMetadatafNeeded()
}
}
private let feedMetadataPath: String
private typealias FeedMetadataDictionary = [String: FeedMetadata]
private var feedMetadata = FeedMetadataDictionary()
private var feedMetadataDirty = false {
didSet {
queueSaveFeedMetadataIfNeeded()
}
}
private var startingUp = true
public var dirty = false {
didSet {
queueSaveToDiskIfNeeded()
}
}
public var unreadCount = 0 {
didSet {
if unreadCount != oldValue {
postUnreadCountDidChangeNotification()
}
}
}
public var isTagBasedSystem: Bool {
return delegate.isTagBasedSystem
}
public var isOPMLImportSupported: Bool {
return delegate.isOPMLImportSupported
}
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
}
var isSubfoldersSupported: Bool {
return delegate.isSubfoldersSupported
2017-09-28 22:16:47 +02:00
}
init?(dataFolder: String, type: AccountType, accountID: String, transport: Transport? = nil) {
switch type {
case .onMyMac:
self.delegate = LocalAccountDelegate()
case .feedbin:
self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport)
2019-06-20 14:22:51 +02:00
case .freshRSS:
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
default:
fatalError("Only Local and Feedbin accounts are supported")
}
2017-09-18 02:03:58 +02:00
self.accountID = accountID
self.type = type
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)
self.feedMetadataPath = (dataFolder as NSString).appendingPathComponent("FeedMetadata.plist")
self.metadataPath = (dataFolder as NSString).appendingPathComponent("Settings.plist")
switch type {
case .onMyMac:
defaultName = Account.defaultLocalAccountName
case .feedly:
defaultName = "Feedly"
case .feedbin:
defaultName = "Feedbin"
case .feedWrangler:
defaultName = "FeedWrangler"
case .newsBlur:
defaultName = "NewsBlur"
2019-06-20 14:22:51 +02:00
case .freshRSS:
defaultName = "FreshRSS"
}
NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
pullObjectsFromDisk()
DispatchQueue.main.async {
self.fetchAllUnreadCounts()
}
self.delegate.accountDidInitialize(self)
startingUp = false
}
2017-09-17 20:32:58 +02:00
// MARK: - API
2019-05-04 22:14:49 +02:00
public func storeCredentials(_ credentials: Credentials) throws {
2019-05-05 10:25:21 +02:00
guard let server = delegate.server else {
2019-05-04 22:14:49 +02:00
throw CredentialsError.incompleteCredentials
}
2019-05-05 10:25:21 +02:00
switch credentials {
case .basic(let username, _):
self.username = username
case .readerAPIBasicLogin(let username, _):
self.username = username
case .readerAPIAuthLogin(let username, _):
self.username = username
2019-05-04 22:14:49 +02:00
}
2019-05-05 10:25:21 +02:00
try CredentialsManager.storeCredentials(credentials, server: server)
2019-05-04 22:14:49 +02:00
delegate.credentials = credentials
2019-05-04 22:14:49 +02:00
}
public func retrieveCredentials() throws -> Credentials? {
switch type {
case .feedbin:
guard let username = self.username, let server = delegate.server else {
return nil
}
return try CredentialsManager.retrieveBasicCredentials(server: server, username: username)
case .freshRSS:
guard let username = self.username, let server = delegate.server else {
return nil
}
return try CredentialsManager.retrieveReaderAPIAuthCredentials(server: server, username: username)
default:
return nil
}
}
public func removeCredentials() throws {
switch type {
case .feedbin:
guard let username = self.username, let server = delegate.server else {
return
}
try CredentialsManager.removeBasicCredentials(server: server, username: username)
self.username = nil
case .freshRSS:
guard let username = self.username, let server = delegate.server else {
return
}
try CredentialsManager.removeReaderAPIAuthCredentials(server: server, username: username)
self.username = nil
default:
break
}
}
public static func validateCredentials(transport: Transport = URLSession.webserviceTransport(), type: AccountType, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result<Credentials?, Error>) -> Void) {
switch type {
case .onMyMac:
2019-05-06 17:53:20 +02:00
LocalAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
case .feedbin:
2019-05-06 17:53:20 +02:00
FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
2019-06-20 14:22:51 +02:00
case .freshRSS:
ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion)
default:
break
}
}
2017-09-17 20:32:58 +02:00
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
self.delegate.refreshAll(for: self, completion: completion)
}
public func syncArticleStatus(completion: (() -> Void)? = nil) {
delegate.sendArticleStatus(for: self) { [unowned self] in
self.delegate.refreshArticleStatus(for: self) {
completion?()
}
}
}
public func importOPML(_ opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
guard !delegate.isOPMLImportInProgress else {
completion(.failure(AccountError.opmlImportInProgress))
return
}
delegate.importOPML(for: self, opmlFile: opmlFile) { [weak self] result in
switch result {
case .success:
guard let self = self else { return }
// Reset the last fetch date to get the article history for the added feeds.
self.metadata.lastArticleFetch = nil
self.delegate.refreshAll(for: self, completion: completion)
case .failure(let error):
completion(.failure(error))
}
}
2017-09-17 21:08:50 +02:00
}
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag)
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)
folders!.insert(folder)
structureDidChange()
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)
}
2019-05-11 23:07:27 +02:00
func newFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> Feed {
let feedURL = opmlFeedSpecifier.feedURL
let metadata = feedMetadata(feedURL: feedURL, feedID: feedURL)
let feed = Feed(account: self, url: opmlFeedSpecifier.feedURL, metadata: metadata)
if let feedTitle = opmlFeedSpecifier.title {
if feed.name == nil {
feed.name = feedTitle
}
}
return feed
}
public func addFeed(_ feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.addFeed(for: self, with: feed, to: container, completion: completion)
}
public func createFeed(url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
delegate.createFeed(for: self, url: url, name: name, container: container, completion: completion)
}
2019-05-09 00:41:19 +02:00
func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed {
let metadata = feedMetadata(feedURL: url, feedID: feedID)
let feed = Feed(account: self, url: url, metadata: metadata)
feed.name = name
feed.homePageURL = homePageURL
2017-10-01 01:56:48 +02:00
return feed
}
public func removeFeed(_ feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.removeFeed(for: self, with: feed, from: container, completion: completion)
2019-05-09 23:09:21 +02:00
}
public func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.moveFeed(for: self, with: feed, from: from, to: to, completion: completion)
2019-05-09 23:09:21 +02:00
}
2019-05-09 00:41:19 +02:00
public func renameFeed(_ feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.renameFeed(for: self, with: feed, to: name, completion: completion)
}
public func restoreFeed(_ feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.restoreFeed(for: self, feed: feed, container: container, completion: completion)
2019-05-09 23:09:21 +02:00
}
public func addFolder(_ name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
delegate.addFolder(for: self, name: name, completion: completion)
2019-05-09 23:09:21 +02:00
}
public func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.removeFolder(for: self, with: folder, completion: completion)
}
2019-05-06 17:53:20 +02:00
public func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
2019-05-07 00:34:41 +02:00
delegate.renameFolder(for: self, with: folder, to: name, completion: completion)
2019-05-06 17:53:20 +02:00
}
2019-05-09 23:09:21 +02:00
public func restoreFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.restoreFolder(for: self, folder: folder, completion: completion)
}
func clearFeedMetadata(_ feed: Feed) {
feedMetadata[feed.url] = nil
}
2019-05-09 23:09:21 +02:00
func addFolder(_ folder: Folder) {
folders!.insert(folder)
postChildrenDidChangeNotification()
structureDidChange()
}
func loadOPML(_ opmlDocument: RSOPMLDocument) {
guard let children = opmlDocument.children else {
return
}
loadOPMLItems(children, parentFolder: nil)
structureDidChange()
DispatchQueue.main.async {
self.refreshAll() { result in }
}
}
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(_ fetchType: FetchType) -> Set<Article> {
switch fetchType {
case .starred:
return fetchStarredArticles()
case .unread:
return fetchUnreadArticles()
case .today:
return fetchTodayArticles()
case .unreadForFolder(let folder):
return fetchArticles(folder: folder)
case .feed(let feed):
return fetchArticles(feed: feed)
case .articleIDs(let articleIDs):
return fetchArticles(articleIDs: articleIDs)
case .search(let searchString):
return fetchArticlesMatching(searchString)
}
}
public func fetchArticlesAsync(_ fetchType: FetchType, _ callback: @escaping ArticleSetBlock) {
switch fetchType {
case .starred:
fetchStarredArticlesAsync(callback)
case .unread:
fetchUnreadArticlesAsync(callback)
case .today:
fetchTodayArticlesAsync(callback)
case .unreadForFolder(let folder):
fetchArticlesAsync(folder: folder, callback)
case .feed(let feed):
fetchArticlesAsync(feed: feed, callback)
case .articleIDs(let articleIDs):
fetchArticlesAsync(articleIDs: articleIDs, callback)
case .search(let searchString):
fetchArticlesMatchingAsync(searchString, callback)
}
}
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
database.fetchUnreadCountForToday(for: flattenedFeeds().feedIDs(), callback: callback)
}
public func fetchUnreadCountForStarredArticles(_ callback: @escaping (Int) -> Void) {
database.fetchStarredAndUnreadCount(for: flattenedFeeds().feedIDs(), callback: callback)
}
public func fetchUnreadArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
database.fetchUnreadArticleIDs(callback)
}
public func fetchStarredArticleIDs(_ callback: @escaping (Set<String>) -> Void) {
database.fetchStarredArticleIDs(callback)
}
public func fetchArticleIDsForStatusesWithoutArticles(_ callback: @escaping (Set<String>) -> Void) {
database.fetchArticleIDsForStatusesWithoutArticles(callback)
}
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
}
public func structureDidChange() {
// Feeds were added or deleted. Or folders added or deleted.
// Or feeds inside folders were added or deleted.
if !startingUp {
dirty = true
}
flattenedFeedsNeedUpdate = true
feedDictionaryNeedsUpdate = true
}
func update(_ feed: Feed, with parsedFeed: ParsedFeed, _ completion: @escaping (() -> Void)) {
feed.takeSettings(from: parsedFeed)
2019-05-13 01:32:32 +02:00
update(feed, parsedItems: parsedFeed.items, completion)
}
func update(_ feed: Feed, parsedItems: Set<ParsedItem>, defaultRead: Bool = false, _ completion: @escaping (() -> Void)) {
database.update(feedID: feed.feedID, parsedItems: parsedItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in
var userInfo = [String: Any]()
if let newArticles = newArticles, !newArticles.isEmpty {
self.updateUnreadCounts(for: Set([feed]))
userInfo[UserInfoKey.newArticles] = newArticles
}
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
userInfo[UserInfoKey.feeds] = Set([feed])
completion()
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
}
}
2019-05-13 01:32:32 +02:00
@discardableResult
func update(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
// Returns set of Articles whose statuses did change.
guard !articles.isEmpty, 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
}
func ensureStatuses(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool) {
if !articleIDs.isEmpty {
database.ensureStatuses(articleIDs, statusKey, flag)
}
}
// MARK: - Container
public func flattenedFeeds() -> Set<Feed> {
assert(Thread.isMainThread)
if flattenedFeedsNeedUpdate {
updateFlattenedFeeds()
}
return _flattenedFeeds
}
public func removeFeed(_ feed: Feed) {
2019-05-09 14:25:45 +02:00
topLevelFeeds.remove(feed)
structureDidChange()
postChildrenDidChangeNotification()
}
public func addFeed(_ feed: Feed) {
topLevelFeeds.insert(feed)
structureDidChange()
postChildrenDidChangeNotification()
}
func addFeedIfNotInAnyFolder(_ feed: Feed) {
if !flattenedFeeds().contains(feed) {
addFeed(feed)
}
}
func removeFolder(_ folder: Folder) {
folders?.remove(folder)
structureDidChange()
postChildrenDidChangeNotification()
}
2019-05-07 00:34:41 +02:00
// MARK: - Debug
public func debugDropConditionalGetInfo() {
#if DEBUG
flattenedFeeds().forEach{ $0.debugDropConditionalGetInfo() }
#endif
}
public func debugRunSearch() {
#if DEBUG
let t1 = Date()
let articles = fetchArticlesMatching("Brent NetNewsWire")
let t2 = Date()
print(t2.timeIntervalSince(t1))
print(articles.count)
#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) {
if let feed = note.object as? Feed, feed.account === self {
updateUnreadCount()
}
}
@objc func batchUpdateDidPerform(_ note: Notification) {
flattenedFeedsNeedUpdate = true
rebuildFeedDictionaries()
updateUnreadCount()
}
@objc func childrenDidChange(_ note: Notification) {
guard let object = note.object else {
return
}
if let account = object as? Account, account === self {
structureDidChange()
updateUnreadCount()
}
if let folder = object as? Folder, folder.account === self {
structureDidChange()
}
}
@objc func displayNameDidChange(_ note: Notification) {
if let folder = note.object as? Folder, folder.account === self {
structureDidChange()
}
}
@objc func saveToDiskIfNeeded() {
if dirty && !isDeleted {
saveToDisk()
}
}
@objc func saveFeedMetadataIfNeeded() {
if feedMetadataDirty && !isDeleted {
saveFeedMetadata()
}
}
@objc func saveAccountMetadataIfNeeded() {
if metadataDirty && !isDeleted {
saveAccountMetadata()
}
}
// 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 {
return lhs === rhs
}
}
// MARK: - AccountMetadataDelegate
extension Account: AccountMetadataDelegate {
func valueDidChange(_ accountMetadata: AccountMetadata, key: AccountMetadata.CodingKeys) {
metadataDirty = true
}
}
// MARK: - FeedMetadataDelegate
extension Account: FeedMetadataDelegate {
func valueDidChange(_ feedMetadata: FeedMetadata, key: FeedMetadata.CodingKeys) {
feedMetadataDirty = true
guard let feed = existingFeed(with: feedMetadata.feedID) else {
return
}
feed.postFeedSettingDidChangeNotification(key)
}
}
// MARK: - Fetching (Private)
private extension Account {
func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(flattenedFeeds().feedIDs())
}
func fetchStarredArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchedStarredArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchUnreadArticles() -> Set<Article> {
return fetchUnreadArticles(forContainer: self)
}
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: self, callback)
}
func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(flattenedFeeds().feedIDs())
}
func fetchTodayArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchTodayArticlesAsync(flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(folder: Folder) -> Set<Article> {
return fetchUnreadArticles(forContainer: folder)
}
func fetchArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) {
fetchUnreadArticlesAsync(forContainer: folder, callback)
}
func fetchArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(feed.feedID)
validateUnreadCount(feed, articles)
return articles
}
func fetchArticlesAsync(feed: Feed, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesAsync(feed.feedID) { [weak self] (articles) in
self?.validateUnreadCount(feed, articles)
callback(articles)
}
}
func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs())
}
func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback)
}
func fetchArticles(articleIDs: Set<String>) -> Set<Article> {
return database.fetchArticles(articleIDs: articleIDs)
}
func fetchArticlesAsync(articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
return database.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
func fetchUnreadArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(Set([feed.feedID]))
validateUnreadCount(feed, articles)
return articles
}
func fetchUnreadArticlesAsync(for feed: Feed, callback: @escaping (Set<Article>) -> Void) {
// database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in
// self?.validateUnreadCount(feed, articles)
// callback(articles)
// }
}
func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(feeds.feedIDs())
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
return articles
}
func fetchUnreadArticlesAsync(forContainer container: Container, _ callback: @escaping ArticleSetBlock) {
let feeds = container.flattenedFeeds()
database.fetchUnreadArticlesAsync(feeds.feedIDs()) { [weak self] (articles) in
self?.validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
callback(articles)
}
}
func validateUnreadCountsAfterFetchingUnreadArticles(_ feeds: Set<Feed>, _ articles: Set<Article>) {
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
for article in articles where !article.status.read {
unreadCountStorage[article.feedID, default: 0] += 1
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
}
}
func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the 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
}
}
// MARK: - Disk (Private)
private extension Account {
func queueSaveToDiskIfNeeded() {
Account.saveQueue.add(self, #selector(saveToDiskIfNeeded))
}
func pullObjectsFromDisk() {
loadAccountMetadata()
loadFeedMetadata()
loadOPMLFile(path: opmlFilePath)
}
func loadAccountMetadata() {
let url = URL(fileURLWithPath: metadataPath)
guard let data = try? Data(contentsOf: url) else {
metadata.delegate = self
return
}
let decoder = PropertyListDecoder()
metadata = (try? decoder.decode(AccountMetadata.self, from: data)) ?? AccountMetadata()
metadata.delegate = self
}
func loadFeedMetadata() {
let url = URL(fileURLWithPath: feedMetadataPath)
guard let data = try? Data(contentsOf: url) else {
return
}
let decoder = PropertyListDecoder()
feedMetadata = (try? decoder.decode(FeedMetadataDictionary.self, from: data)) ?? FeedMetadataDictionary()
feedMetadata.values.forEach { $0.delegate = self }
}
func loadOPMLFile(path: String) {
let opmlFileURL = URL(fileURLWithPath: path)
var fileData: Data?
do {
fileData = try Data(contentsOf: opmlFileURL)
} catch {
2018-09-27 07:05:35 +02:00
// Commented out because its not an error on first run.
// TODO: make it so we know if its first run or not.
//NSApplication.shared.presentError(error)
return
}
guard let opmlData = fileData else {
return
}
let parserData = ParserData(url: opmlFileURL.absoluteString, data: opmlData)
var opmlDocument: RSOPMLDocument?
do {
opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
} catch {
os_log(.error, log: log, "OPML Import failed: %@.", error.localizedDescription)
return
}
guard let parsedOPML = opmlDocument, let children = parsedOPML.children else {
return
}
BatchUpdate.shared.perform {
loadOPMLItems(children, parentFolder: nil)
}
}
func saveToDisk() {
2018-09-14 22:25:38 +02:00
dirty = false
let opmlDocumentString = opmlDocument()
do {
let url = URL(fileURLWithPath: opmlFilePath)
try opmlDocumentString.write(to: url, atomically: true, encoding: .utf8)
}
catch let error as NSError {
os_log(.error, log: log, "Save to disk failed: %@.", error.localizedDescription)
2018-09-14 22:25:38 +02:00
}
}
func queueSaveFeedMetadataIfNeeded() {
Account.saveQueue.add(self, #selector(saveFeedMetadataIfNeeded))
}
private func metadataForOnlySubscribedToFeeds() -> FeedMetadataDictionary {
let feedIDs = idToFeedDictionary.keys
return feedMetadata.filter { (feedID: String, metadata: FeedMetadata) -> Bool in
return feedIDs.contains(metadata.feedID)
}
}
func saveFeedMetadata() {
feedMetadataDirty = false
let d = metadataForOnlySubscribedToFeeds()
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let url = URL(fileURLWithPath: feedMetadataPath)
do {
let data = try encoder.encode(d)
try data.write(to: url)
}
catch {
assertionFailure(error.localizedDescription)
}
}
func queueSaveAccountMetadatafNeeded() {
Account.saveQueue.add(self, #selector(saveAccountMetadataIfNeeded))
}
func saveAccountMetadata() {
metadataDirty = false
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let url = URL(fileURLWithPath: metadataPath)
do {
let data = try encoder.encode(metadata)
try data.write(to: url)
}
catch {
assertionFailure(error.localizedDescription)
}
}
}
2017-10-01 19:59:35 +02:00
// MARK: - Private
private extension Account {
func feedMetadata(feedURL: String, feedID: String) -> FeedMetadata {
if let d = feedMetadata[feedURL] {
2019-03-17 01:30:30 +01:00
assert(d.delegate === self)
return d
}
let d = FeedMetadata(feedID: feedID)
d.delegate = self
feedMetadata[feedURL] = d
2019-03-17 01:30:30 +01:00
return d
}
func updateFlattenedFeeds() {
var feeds = Set<Feed>()
feeds.formUnion(topLevelFeeds)
for folder in folders! {
feeds.formUnion(folder.flattenedFeeds())
}
_flattenedFeeds = feeds
flattenedFeedsNeedUpdate = false
}
func rebuildFeedDictionaries() {
var idDictionary = [String: Feed]()
flattenedFeeds().forEach { (feed) in
idDictionary[feed.feedID] = feed
}
_idToFeedDictionary = idDictionary
feedDictionaryNeedsUpdate = false
2017-10-01 19:59:35 +02:00
}
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
var feedsToAdd = Set<Feed>()
2017-10-22 06:00:21 +02:00
items.forEach { (item) in
if let feedSpecifier = item.feedSpecifier {
2019-05-11 23:07:27 +02:00
let feed = newFeed(with: feedSpecifier)
feedsToAdd.insert(feed)
2017-10-22 06:00:21 +02:00
return
}
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.
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: parentFolder)
}
2017-10-22 06:00:21 +02:00
return
}
2017-10-22 06:00:21 +02:00
if let folder = ensureFolder(with: folderName) {
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: folder)
}
}
}
if let parentFolder = parentFolder {
for feed in feedsToAdd {
parentFolder.addFeed(feed)
}
} else {
for feed in feedsToAdd {
addFeed(feed)
}
}
}
func updateUnreadCount() {
if fetchingAllUnreadCounts {
return
}
var updatedUnreadCount = 0
for feed in flattenedFeeds() {
updatedUnreadCount += feed.unreadCount
}
unreadCount = updatedUnreadCount
}
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() {
fetchingAllUnreadCounts = true
2019-07-09 08:06:40 +02:00
database.fetchAllNonZeroUnreadCounts { (unreadCountDictionary) in
if unreadCountDictionary.isEmpty {
2018-09-27 07:05:35 +02:00
self.fetchingAllUnreadCounts = false
self.updateUnreadCount()
self.isUnreadCountsInitialized = true
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.fetchingAllUnreadCounts = false
self.updateUnreadCount()
self.isUnreadCountsInitialized = true
}
}
}
// MARK: - Container Overrides
extension Account {
public func existingFeed(with feedID: String) -> Feed? {
return idToFeedDictionary[feedID]
}
}
// MARK: - OPMLRepresentable
extension Account: OPMLRepresentable {
public func OPMLString(indentLevel: Int) -> String {
var s = ""
for feed in topLevelFeeds {
s += feed.OPMLString(indentLevel: indentLevel + 1)
}
for folder in folders! {
s += folder.OPMLString(indentLevel: indentLevel + 1)
}
return s
}
}