diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index aac8d5d38..a70e6873e 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -37,6 +37,7 @@ public enum AccountType: Int { public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable { + public struct UserInfoKey { public static let newArticles = "newArticles" // AccountDidDownloadArticles public static let updatedArticles = "updatedArticles" // AccountDidDownloadArticles @@ -48,8 +49,20 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public let accountID: String public let type: AccountType public var nameForDisplay = "" - public var children = [AnyObject]() - var idToFeedDictionary = [String: Feed]() + public var topLevelFeeds = Set() + public var folders: Set? = Set() + + private var feedDictionaryNeedsUpdate = true + private var _idToFeedDictionary = [String: Feed]() + var idToFeedDictionary: [String: Feed] { + if feedDictionaryNeedsUpdate { + rebuildFeedDictionaries() + } + return _idToFeedDictionary + } + + private var fetchingAllUnreadCounts = false + let settingsFile: String let dataFolder: String let database: ArticlesDatabase @@ -65,6 +78,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, private var unreadCounts = [String: Int]() // [feedID: Int] private let opmlFilePath: String + private var _flattenedFeeds = Set() + private var flattenedFeedsNeedUpdate = true + private struct SettingsKey { static let unreadCount = "unreadCount" } @@ -204,7 +220,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } let folder = Folder(account: self, name: name) - children += [folder] + folders!.insert(folder) dirty = true postChildrenDidChangeNotification() @@ -244,21 +260,30 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, didAddFeed = folder.addFeed(feed) } else { - if !topLevelObjectsContainsFeed(feed) { - children += [feed] + if !topLevelFeeds.contains(feed) { + topLevelFeeds.insert(feed) postChildrenDidChangeNotification() + didAddFeed = true } - didAddFeed = true } if didAddFeed { - addToFeedDictionaries(feed) - dirty = true + structureDidChange() } - + return didAddFeed } + public func addFeeds(_ feeds: Set, to folder: Folder?) { + if let folder = folder { + folder.addFeeds(feeds) + } + else { + topLevelFeeds.formUnion(feeds) + } + structureDidChange() + } + public func createFeed(with name: String?, editedName: String?, url: String) -> Feed? { // For syncing, this may need to be an async method with a callback, @@ -285,13 +310,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // TODO: support subfolders, maybe, some day, if one of the sync systems // supports subfolders. But, for now, parentFolder is ignored. - - if objectIsChild(folder) { + if folders!.contains(folder) { return true } - children += [folder] + folders!.insert(folder) postChildrenDidChangeNotification() - rebuildFeedDictionaries() + structureDidChange() return true } @@ -300,9 +324,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, guard let children = opmlDocument.children else { return } - rebuildFeedDictionaries() importOPMLItems(children, parentFolder: nil) - saveToDisk() + structureDidChange() DispatchQueue.main.async { self.refreshAll() @@ -450,6 +473,35 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, 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. + dirty = true + flattenedFeedsNeedUpdate = true + feedDictionaryNeedsUpdate = true + } + + // MARK: - Container + + public func flattenedFeeds() -> Set { + if flattenedFeedsNeedUpdate { + updateFlattenedFeeds() + } + return _flattenedFeeds + } + + public func deleteFeed(_ feed: Feed) { + topLevelFeeds.remove(feed) + structureDidChange() + postChildrenDidChangeNotification() + } + + public func deleteFolder(_ folder: Folder) { + folders?.remove(folder) + structureDidChange() + postChildrenDidChangeNotification() + } + // MARK: - Debug public func debugDropConditionalGetInfo() { @@ -482,6 +534,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, @objc func batchUpdateDidPerform(_ note: Notification) { + flattenedFeedsNeedUpdate = true rebuildFeedDictionaries() updateUnreadCount() } @@ -492,17 +545,17 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return } if let account = object as? Account, account === self { - dirty = true + structureDidChange() } if let folder = object as? Folder, folder.account === self { - dirty = true + structureDidChange() } } @objc func displayNameDidChange(_ note: Notification) { if let folder = note.object as? Folder, folder.account === self { - dirty = true + structureDidChange() } } @@ -582,11 +635,20 @@ private extension Account { 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) + let children = objects(with: childrenArray) + var feeds = Set() + var folders = Set() + for oneChild in children { + if let feed = oneChild as? Feed { + feeds.insert(feed) + } + else if let folder = oneChild as? Folder { + folders.insert(folder) + } + } + self.topLevelFeeds = feeds + self.folders = folders + structureDidChange() // Rename plist file so we don’t see it next time. let renamedFilePath = (dataFolder as NSString).appendingPathComponent("AccountData-old.plist") @@ -624,11 +686,13 @@ private extension Account { NSApplication.shared.presentError(error) return } - guard let parsedOPML = opmlDocument else { + guard let parsedOPML = opmlDocument, let children = parsedOPML.children else { return } - importOPML(parsedOPML) + BatchUpdate.shared.perform { + importOPMLItems(children, parentFolder: nil) + } } func saveToDisk() { @@ -650,50 +714,47 @@ private extension Account { private extension Account { + func updateFlattenedFeeds() { + var feeds = Set() + feeds.formUnion(topLevelFeeds) + for folder in folders! { + feeds.formUnion(folder.flattenedFeeds()) + } + + _flattenedFeeds = feeds + flattenedFeedsNeedUpdate = false + } + func rebuildFeedDictionaries() { - var urlDictionary = [String: Feed]() var idDictionary = [String: Feed]() flattenedFeeds().forEach { (feed) in - urlDictionary[feed.url] = feed idDictionary[feed.feedID] = feed } - idToFeedDictionary = idDictionary - } - - func addToFeedDictionaries(_ feed: Feed) { - - idToFeedDictionary[feed.feedID] = feed - } - - func topLevelObjectsContainsFeed(_ feed: Feed) -> Bool { - - return children.contains(where: { (object) -> Bool in - if let oneFeed = object as? Feed { - if oneFeed.feedID == feed.feedID { - return true - } - } - return false - }) + _idToFeedDictionary = idDictionary + feedDictionaryNeedsUpdate = false } func createFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> Feed { let feed = Feed(account: self, url: opmlFeedSpecifier.feedURL, feedID: opmlFeedSpecifier.feedURL) - feed.editedName = opmlFeedSpecifier.title + if let feedTitle = opmlFeedSpecifier.title, feed.editedName == nil { + feed.editedName = feedTitle + } return feed } func importOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) { + var feedsToAdd = Set() + items.forEach { (item) in if let feedSpecifier = item.feedSpecifier { let feed = createFeed(with: feedSpecifier) - addFeed(feed, to: parentFolder) + feedsToAdd.insert(feed) return } @@ -713,11 +774,21 @@ private extension Account { importOPMLItems(itemChildren, parentFolder: folder) } } + + if !feedsToAdd.isEmpty { + addFeeds(feedsToAdd, to: parentFolder) + } } func updateUnreadCount() { - - unreadCount = calculateUnreadCount(flattenedFeeds()) + if fetchingAllUnreadCounts { + return + } + var updatedUnreadCount = 0 + for feed in flattenedFeeds() { + updatedUnreadCount += feed.unreadCount + } + unreadCount = updatedUnreadCount } func noteStatusesForArticlesDidChange(_ articles: Set
) { @@ -734,6 +805,7 @@ private extension Account { func fetchAllUnreadCounts() { + fetchingAllUnreadCounts = true database.fetchAllNonZeroUnreadCounts { (unreadCountDictionary) in if unreadCountDictionary.isEmpty { @@ -751,6 +823,7 @@ private extension Account { feed.unreadCount = 0 } } + self.fetchingAllUnreadCounts = false self.updateUnreadCount() } } @@ -764,6 +837,7 @@ extension Account { return idToFeedDictionary[feedID] } + } // MARK: - OPMLRepresentable @@ -773,10 +847,11 @@ 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) - } + for feed in topLevelFeeds { + s += feed.OPMLString(indentLevel: indentLevel + 1) + } + for folder in folders! { + s += folder.OPMLString(indentLevel: indentLevel + 1) } return s } diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 5063690ed..a29f2de4b 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -18,7 +18,8 @@ extension Notification.Name { public protocol Container: class { - var children: [AnyObject] { get set } + var topLevelFeeds: Set { get set } + var folders: Set? { get set } func hasAtLeastOneFeed() -> Bool func objectIsChild(_ object: AnyObject) -> Bool @@ -29,7 +30,7 @@ public protocol Container: class { func deleteFeed(_ feed: Feed) func deleteFolder(_ folder: Folder) - //Recursive + //Recursive — checks subfolders func flattenedFeeds() -> Set func hasFeed(with feedID: String) -> Bool func hasFeed(withURL url: String) -> Bool @@ -44,43 +45,31 @@ public protocol Container: class { public extension Container { func hasAtLeastOneFeed() -> Bool { - - for child in children { - if child is Feed { - return true - } - if let folder = child as? Folder { - if folder.hasAtLeastOneFeed() { - return true - } - } - } - - return false + return topLevelFeeds.count > 0 } func hasChildFolder(with name: String) -> Bool { - return childFolder(with: name) != nil } func childFolder(with name: String) -> Folder? { - - for child in children { - if let folder = child as? Folder, folder.name == name { + guard let folders = folders else { + return nil + } + for folder in folders { + if folder.name == name { return folder } } - return nil } func objectIsChild(_ object: AnyObject) -> Bool { - - for child in children { - if object === child { - return true - } + if let feed = object as? Feed { + return topLevelFeeds.contains(feed) + } + if let folder = object as? Folder { + return folders?.contains(folder) ?? false } return false } @@ -88,123 +77,74 @@ public extension Container { func flattenedFeeds() -> Set { var feeds = Set() - - for object in children { - if let feed = object as? Feed { - feeds.insert(feed) - } - else if let container = object as? Container { - feeds.formUnion(container.flattenedFeeds()) + feeds.formUnion(topLevelFeeds) + if let folders = folders { + for folder in folders { + feeds.formUnion(folder.flattenedFeeds()) } } - return feeds } func hasFeed(with feedID: String) -> Bool { - return existingFeed(with: feedID) != nil } func hasFeed(withURL url: String) -> Bool { - return existingFeed(withURL: url) != nil } func existingFeed(with feedID: String) -> Feed? { - - for child in children { - - if let feed = child as? Feed, feed.feedID == feedID { - return feed - } - if let container = child as? Container, let feed = container.existingFeed(with: feedID) { + for feed in flattenedFeeds() { + if feed.feedID == feedID { return feed } } - return nil } func existingFeed(withURL url: String) -> Feed? { - - for child in children { - - if let feed = child as? Feed, feed.url == url { - return feed - } - if let container = child as? Container, let feed = container.existingFeed(withURL: url) { + for feed in flattenedFeeds() { + if feed.url == url { return feed } } - return nil } func existingFolder(with name: String) -> Folder? { - - for child in children { - - if let folder = child as? Folder { - if folder.name == name { - return folder - } - if let subFolder = folder.existingFolder(with: name) { - return subFolder - } - } + guard let folders = folders else { + return nil } + for folder in folders { + if folder.name == name { + return folder + } + if let subFolder = folder.existingFolder(with: name) { + return subFolder + } + } return nil } func existingFolder(withID folderID: Int) -> Folder? { - - for child in children { - - if let folder = child as? Folder { - if folder.folderID == folderID { - return folder - } - if let subFolder = folder.existingFolder(withID: folderID) { - return subFolder - } - } + guard let folders = folders else { + return nil } + for folder in folders { + if folder.folderID == folderID { + return folder + } + if let subFolder = folder.existingFolder(withID: folderID) { + return subFolder + } + } return nil } - func indexOf(_ object: T) -> Int? { - - return children.index(where: { (child) -> Bool in - if let oneObject = child as? T { - return oneObject == object - } - return false - }) - } - - func delete(_ object: T) { - - if let index = indexOf(object) { - children.remove(at: index) - postChildrenDidChangeNotification() - } - } - - func deleteFeed(_ feed: Feed) { - - return delete(feed) - } - - func deleteFolder(_ folder: Folder) { - - return delete(folder) - } - func postChildrenDidChangeNotification() { - NotificationCenter.default.post(name: .ChildrenDidChange, object: self) } } diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index 3eb80514d..10e48b5f8 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -10,11 +10,13 @@ import Foundation import Articles import RSCore -public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, Hashable { +public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, Hashable { + public weak var account: Account? - public var children = [AnyObject]() - + public var topLevelFeeds: Set = Set() + public var folders: Set? = nil // subfolders are not supported, so this is always nil + public var name: String? { didSet { postDisplayNameDidChangeNotification() @@ -69,21 +71,28 @@ public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, self.init(account: account, name: name) if let childrenArray = dictionary[Key.children] as? [[String: Any]] { - self.children = Folder.objects(with: childrenArray, account: account) + self.topLevelFeeds = Folder.feedsOnly(with: childrenArray, account: account) } } // MARK: Feeds - + + /// Add a single feed. Return true if number of feeds in folder changes. func addFeed(_ feed: Feed) -> Bool { - - // Return true in the case where the feed is already a child. - - if !childrenContain(feed) { - children += [feed] + return addFeeds(Set([feed])) + } + + /// Add one or more feeds. Return true if number of feeds in folder changes. + @discardableResult + func addFeeds(_ feedsToAdd: Set) -> Bool { + let feedCount = topLevelFeeds.count + topLevelFeeds.formUnion(feedsToAdd) + + if feedCount != topLevelFeeds.count { postChildrenDidChangeNotification() + return true } - return true + return false } // MARK: - Notifications @@ -102,6 +111,30 @@ public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, updateUnreadCount() } + // MARK: Container + + public func flattenedFeeds() -> Set { + // Since sub-folders are not supported, it’s always the top-level feeds. + return topLevelFeeds + } + + public func objectIsChild(_ object: AnyObject) -> Bool { + // Folders contain Feed objects only, at least for now. + guard let feed = object as? Feed else { + return false + } + return topLevelFeeds.contains(feed) + } + + public func deleteFeed(_ feed: Feed) { + topLevelFeeds.remove(feed) + postChildrenDidChangeNotification() + } + + public func deleteFolder(_ folder: Folder) { + // Nothing to do + } + // MARK: - Hashable public func hash(into hasher: inout Hasher) { @@ -121,17 +154,15 @@ public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, private extension Folder { func updateUnreadCount() { - - unreadCount = calculateUnreadCount(children) + var updatedUnreadCount = 0 + for feed in topLevelFeeds { + updatedUnreadCount += feed.unreadCount + } + unreadCount = updatedUnreadCount } func childrenContain(_ feed: Feed) -> Bool { - return children.contains(where: { (object) -> Bool in - if let oneFeed = object as? Feed { - return oneFeed == feed - } - return false - }) + return topLevelFeeds.contains(feed) } } @@ -139,17 +170,6 @@ private extension Folder { private extension Folder { - static func objects(with diskObjects: [[String: Any]], account: Account) -> [AnyObject] { - - if account.supportsSubFolders { - return account.objects(with: diskObjects) - } - else { - let flattenedFeeds = feedsOnly(with: diskObjects, account: account) - return Array(flattenedFeeds) as [AnyObject] - } - } - static func feedsOnly(with diskObjects: [[String: Any]], account: Account) -> Set { // This Folder doesn’t support subfolders, but they might exist on disk. @@ -187,11 +207,9 @@ extension Folder: OPMLRepresentable { var hasAtLeastOneChild = false - for child in children { - if let opmlObject = child as? OPMLRepresentable { - s += opmlObject.OPMLString(indentLevel: indentLevel + 1) - hasAtLeastOneChild = true - } + for feed in topLevelFeeds { + s += feed.OPMLString(indentLevel: indentLevel + 1) + hasAtLeastOneChild = true } if !hasAtLeastOneChild { diff --git a/NetNewsWire/MainWindow/AddFeed/FolderTreeControllerDelegate.swift b/NetNewsWire/MainWindow/AddFeed/FolderTreeControllerDelegate.swift index 33316954c..23a81abde 100644 --- a/NetNewsWire/MainWindow/AddFeed/FolderTreeControllerDelegate.swift +++ b/NetNewsWire/MainWindow/AddFeed/FolderTreeControllerDelegate.swift @@ -27,15 +27,10 @@ private extension FolderTreeControllerDelegate { // Root node is “Top Level” and children are folders. Folders can’t have subfolders. // This will have to be revised later. - var folderNodes = [Node]() - - for oneRepresentedObject in AccountManager.shared.localAccount.children { - - if let folder = oneRepresentedObject as? Folder { - folderNodes += [createNode(folder, parent: node)] - } + guard let folders = AccountManager.shared.localAccount.folders else { + return nil } - + let folderNodes = folders.map { createNode($0, parent: node) } return folderNodes.sortedAlphabetically() } diff --git a/NetNewsWire/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift b/NetNewsWire/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift index bbde3c627..694ed4645 100644 --- a/NetNewsWire/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift +++ b/NetNewsWire/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift @@ -51,9 +51,15 @@ private extension SidebarTreeControllerDelegate { let container = containerNode.representedObject as! Container + var children = [AnyObject]() + children.append(contentsOf: Array(container.topLevelFeeds)) + if let folders = container.folders { + children.append(contentsOf: Array(folders)) + } + var updatedChildNodes = [Node]() - container.children.forEach { (representedObject) in + children.forEach { (representedObject) in if let existingNode = containerNode.childNodeRepresentingObject(representedObject) { if !updatedChildNodes.contains(existingNode) { diff --git a/NetNewsWire/Scriptability/Account+Scriptability.swift b/NetNewsWire/Scriptability/Account+Scriptability.swift index f836a3839..261f8511e 100644 --- a/NetNewsWire/Scriptability/Account+Scriptability.swift +++ b/NetNewsWire/Scriptability/Account+Scriptability.swift @@ -68,34 +68,35 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta @objc(feeds) var feeds:NSArray { - let feeds = account.children.compactMap { $0 as? Feed } - return feeds.map { ScriptableFeed($0, container:self) } as NSArray + return account.topLevelFeeds.map { ScriptableFeed($0, container:self) } as NSArray } @objc(valueInFeedsWithUniqueID:) func valueInFeeds(withUniqueID id:String) -> ScriptableFeed? { - let feeds = account.children.compactMap { $0 as? Feed } + let feeds = Array(account.topLevelFeeds) guard let feed = feeds.first(where:{$0.feedID == id}) else { return nil } return ScriptableFeed(feed, container:self) } @objc(valueInFeedsWithName:) func valueInFeeds(withName name:String) -> ScriptableFeed? { - let feeds = account.children.compactMap { $0 as? Feed } + let feeds = Array(account.topLevelFeeds) guard let feed = feeds.first(where:{$0.name == name}) else { return nil } return ScriptableFeed(feed, container:self) } @objc(folders) var folders:NSArray { - let folders = account.children.compactMap { $0 as? Folder } - return folders.map { ScriptableFolder($0, container:self) } as NSArray + let foldersSet = account.folders ?? Set() + let folders = Array(foldersSet) + return folders.map { ScriptableFolder($0, container:self) } as NSArray } @objc(valueInFoldersWithUniqueID:) func valueInFolders(withUniqueID id:NSNumber) -> ScriptableFolder? { let folderId = id.intValue - let folders = account.children.compactMap { $0 as? Folder } + let foldersSet = account.folders ?? Set() + let folders = Array(foldersSet) guard let folder = folders.first(where:{$0.folderID == folderId}) else { return nil } return ScriptableFolder(folder, container:self) } @@ -105,14 +106,15 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta @objc(contents) var contents:NSArray { var contentsArray:[AnyObject] = [] - for child in account.children { - if let aFeed = child as? Feed { - contentsArray.append(ScriptableFeed(aFeed, container:self)) - } else if let aFolder = child as? Folder { - contentsArray.append(ScriptableFolder(aFolder, container:self)) - } - } - return contentsArray as NSArray + for feed in account.topLevelFeeds { + contentsArray.append(ScriptableFeed(feed, container: self)) + } + if let folders = account.folders { + for folder in folders { + contentsArray.append(ScriptableFolder(folder, container:self)) + } + } + return contentsArray as NSArray } @objc(opmlRepresentation) diff --git a/NetNewsWire/Scriptability/Folder+Scriptability.swift b/NetNewsWire/Scriptability/Folder+Scriptability.swift index d7c8b5a8b..5ef9352ce 100644 --- a/NetNewsWire/Scriptability/Folder+Scriptability.swift +++ b/NetNewsWire/Scriptability/Folder+Scriptability.swift @@ -92,7 +92,7 @@ class ScriptableFolder: NSObject, UniqueIdScriptingObject, ScriptingObjectContai @objc(feeds) var feeds:NSArray { - let feeds = folder.children.compactMap { $0 as? Feed } + let feeds = Array(folder.topLevelFeeds) return feeds.map { ScriptableFeed($0, container:self) } as NSArray } diff --git a/NetNewsWire/Scriptability/NSApplication+Scriptability.swift b/NetNewsWire/Scriptability/NSApplication+Scriptability.swift index 5564f2723..b94de03c9 100644 --- a/NetNewsWire/Scriptability/NSApplication+Scriptability.swift +++ b/NetNewsWire/Scriptability/NSApplication+Scriptability.swift @@ -77,7 +77,7 @@ extension NSApplication : ScriptingObjectContainer { let accounts = AccountManager.shared.accounts let emptyFeeds:[Feed] = [] return accounts.reduce(emptyFeeds) { (result, nthAccount) -> [Feed] in - let accountFeeds = nthAccount.children.compactMap { $0 as? Feed } + let accountFeeds = Array(nthAccount.topLevelFeeds) return result + accountFeeds } }