diff --git a/Commands/DeleteFromSidebarCommand.swift b/Commands/DeleteFromSidebarCommand.swift new file mode 100644 index 000000000..55c115194 --- /dev/null +++ b/Commands/DeleteFromSidebarCommand.swift @@ -0,0 +1,134 @@ +// +// DeleteFromSidebarCommand.swift +// Evergreen +// +// Created by Brent Simmons on 11/4/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import Foundation +import RSCore +import RSTree +import Account +import Data + +final class DeleteFromSidebarCommand: UndoableCommand { + + private struct ActionName { + static private let deleteFeed = NSLocalizedString("Delete Feed", comment: "command") + static private let deleteFeeds = NSLocalizedString("Delete Feeds", comment: "command") + static private let deleteFolder = NSLocalizedString("Delete Folder", comment: "command") + static private let deleteFolders = NSLocalizedString("Delete Folders", comment: "command") + static private let deleteFeedsAndFolders = NSLocalizedString("Delete Feeds and Folders", comment: "command") + } + + let undoActionName: String + var redoActionName: String { + get { + return undoActionName + } + } + + let undoManager: UndoManager + + init?(nodesToDelete: [Node], undoManager: UndoManager) { + + var numberOfFeeds = 0 + var numberOfFolders = 0 + + for node in nodesToDelete { + if let _ = node.representedObject as? Feed { + numberOfFeeds += 1 + } + else if let _ = node.representedObject as? Folder { + numberOfFolders += 1 + } + else { + return nil // Delete only Feeds and Folders. + } + } + + if numberOfFeeds < 1 && numberOfFolders < 1 { + return nil + } + + if numberOfFolders < 1 { + self.undoActionName = numberOfFeeds == 1 ? ActionName.deleteFeed : ActionName.deleteFeeds + } + else if numberOfFeeds < 1 { + self.undoActionName = numberOfFolders == 1 ? ActionName.deleteFolder : ActionName.deleteFolders + } + else { + self.undoActionName = ActionName.deleteFeedsAndFolders + } + + self.undoManager = undoManager + } + + func perform() { + + registerUndo() + } + + func undo() { + + registerRedo() + } + + static func canDelete(_ nodes: [Node]) -> Bool { + + // Return true if all nodes are feeds and folders. + // Any other type: return false. + + if nodes.isEmpty { + return false + } + + for node in nodes { + if let _ = node.representedObject as? Feed { + continue + } + if let _ = node.representedObject as? Folder { + continue + } + return false + } + + return true + } +} + +// Remember as much as we can now about the items being deleted, +// so they can be restored to the correct place. + +private struct SidebarItemSpecifier { + + weak var account: Account? + let node: Node + let folder: Folder? + let feed: Feed? + let path: ContainerPath + + init?(node: Node) { + + self.node = node + + var account: Account? + if let feed = node.representedObject as? Feed { + self.feed = feed + account = feed.account + } + else if let folder = node.representedObject as? Folder { + self.folder = folder + account = folder.account + } + + guard let account = account else { + return nil + } + + self.account = account + self.path = SidebarPath(account: account, node: node) + } +} + diff --git a/Evergreen.xcodeproj/project.pbxproj b/Evergreen.xcodeproj/project.pbxproj index e0861025a..1296546ae 100644 --- a/Evergreen.xcodeproj/project.pbxproj +++ b/Evergreen.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 84B99C671FAE35E600ECDEDB /* FeedListTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C661FAE35E600ECDEDB /* FeedListTreeControllerDelegate.swift */; }; 84B99C691FAE36B800ECDEDB /* FeedListFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C681FAE36B800ECDEDB /* FeedListFolder.swift */; }; 84B99C6B1FAE370B00ECDEDB /* FeedListFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C6A1FAE370B00ECDEDB /* FeedListFeed.swift */; }; + 84B99C9D1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C9C1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift */; }; 84BB4B771F11753300858766 /* Data.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84BB4B681F1174D400858766 /* Data.framework */; }; 84BB4B781F11753300858766 /* Data.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84BB4B681F1174D400858766 /* Data.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 84DAEE301F86CAFE0058304B /* OPMLImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DAEE2F1F86CAFE0058304B /* OPMLImporter.swift */; }; @@ -452,6 +453,7 @@ 84B99C661FAE35E600ECDEDB /* FeedListTreeControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListTreeControllerDelegate.swift; sourceTree = ""; }; 84B99C681FAE36B800ECDEDB /* FeedListFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListFolder.swift; sourceTree = ""; }; 84B99C6A1FAE370B00ECDEDB /* FeedListFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListFeed.swift; sourceTree = ""; }; + 84B99C9C1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFromSidebarCommand.swift; sourceTree = ""; }; 84BB4B611F1174D400858766 /* Data.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Data.xcodeproj; path = Frameworks/Data/Data.xcodeproj; sourceTree = ""; }; 84DAEE2F1F86CAFE0058304B /* OPMLImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLImporter.swift; sourceTree = ""; }; 84DAEE311F870B390058304B /* DockBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DockBadge.swift; path = Evergreen/DockBadge.swift; sourceTree = ""; }; @@ -542,6 +544,7 @@ isa = PBXGroup; children = ( 84702AA31FA27AC0006B8943 /* MarkReadOrUnreadCommand.swift */, + 84B99C9C1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift */, ); path = Commands; sourceTree = ""; @@ -1237,6 +1240,7 @@ 849A97781ED9EC04007D329B /* TimelineCellLayout.swift in Sources */, 849A976C1ED9EBC8007D329B /* TimelineTableRowView.swift in Sources */, 849A977B1ED9EC04007D329B /* UnreadIndicatorView.swift in Sources */, + 84B99C9D1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift in Sources */, 849A97541ED9EAC0007D329B /* AddFeedWindowController.swift in Sources */, 849A976D1ED9EBC8007D329B /* TimelineTableView.swift in Sources */, 84B99C671FAE35E600ECDEDB /* FeedListTreeControllerDelegate.swift in Sources */, diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 845077c08..331d5a377 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -201,6 +201,17 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return folder } + public func ensureFolder(withFolderNames folderNames: [String]) -> Folder? { + + // TODO: support subfolders, maybe, some day. + // Since we don’t, just take the last name and make sure there’s 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. diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 27bc0b7fb..a22ceb14c 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846E77531F6F00E300A165E2 /* AccountManager.swift */; }; 848935001F62484F00CEBD24 /* Account.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848934F61F62484F00CEBD24 /* Account.framework */; }; 848935051F62485000CEBD24 /* AccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848935041F62485000CEBD24 /* AccountTests.swift */; }; + 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */; }; 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */; }; 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */; }; /* End PBXBuildFile section */ @@ -114,6 +115,7 @@ 848935041F62485000CEBD24 /* AccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTests.swift; sourceTree = ""; }; 848935061F62485000CEBD24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 848935101F62486800CEBD24 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; + 84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerPath.swift; sourceTree = ""; }; 84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedRefreshProgress.swift; sourceTree = ""; }; 84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -205,6 +207,7 @@ 848935101F62486800CEBD24 /* Account.swift */, 841974241F6DDCE4006346C4 /* AccountDelegate.swift */, 841974001F6DD1EC006346C4 /* Folder.swift */, + 84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */, 84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */, 84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */, 8419740D1F6DD25F006346C4 /* Container.swift */, @@ -434,6 +437,7 @@ 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, + 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 29e1426d5..a516a87c4 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -33,6 +33,7 @@ public protocol Container { func existingFeed(with feedID: String) -> Feed? func existingFeed(withURL url: String) -> Feed? func existingFolder(with name: String) -> Folder? + func existingFolder(withID: Int) -> Folder? func postChildrenDidChangeNotification() } @@ -154,6 +155,23 @@ public extension Container { 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 + } + } + } + + return nil + } + func postChildrenDidChangeNotification() { NotificationCenter.default.post(name: .ChildrenDidChange, object: self) diff --git a/Frameworks/Account/ContainerPath.swift b/Frameworks/Account/ContainerPath.swift new file mode 100644 index 000000000..2dd3b38d3 --- /dev/null +++ b/Frameworks/Account/ContainerPath.swift @@ -0,0 +1,53 @@ +// +// ContainerPath.swift +// Account +// +// Created by Brent Simmons on 11/4/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Used to identify the parent of an object. +// Mainly used with deleting objects and undo/redo. +// Especially redo. The idea is to put something back in the right place. + +public struct ContainerPath { + + private weak var account: Account? + private let names: [String] // empty if top-level of account + private let folderID: Int? // nil if top-level + private let isTopLevel: Bool + + // folders should be from top-level down, as in ["Cats", "Tabbies"] + + public init(account: Account, folders: [Folder]) { + + self.account = account + self.names = folders.map { $0.nameForDisplay } + self.isTopLevel = folders.isEmpty + + if let lastFolder = folders.last { + self.folderID = lastFolder.folderID + } + } + + public func resolveContainer() -> Container? { + + // The only time it should fail is if the account no longer exists. + // Otherwise the worst-case scenario is that it will create Folders if needed. + + guard let account = account else { + return nil + } + if isTopLevel { + return account + } + + if let folderID = folderID, let folder = account.existingFolder(withID: folderID) { + return folder + } + + return account.ensureFolder(withFolderNames: names) + } +} diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index b82905f36..92a61d0bd 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -12,11 +12,12 @@ import RSCore public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, Hashable { - public weak var account: Account? public var children = [AnyObject]() var name: String? static let untitledName = NSLocalizedString("Untitled ƒ", comment: "Folder name") + public let folderID: Int // not saved: per-run only + static var incrementingID = 0 public let hashValue: Int // MARK: - Fetching Articles @@ -54,8 +55,12 @@ public final class Folder: DisplayNameProvider, Container, UnreadCountProvider, self.account = account self.name = name - self.hashValue = name?.hashValue ?? Folder.untitledName.hashValue - + + let folderID = Folder.incrementingID + Folder.incrementingID += 1 + self.folderID = folderID + self.hashValue = folderID + NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) }