diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..1f32a4fa1 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,58 @@ +# iOS CircleCI 2.0 configuration file +# +version: 2 +jobs: + build: + + # Specify the Xcode version to use + macos: + xcode: "10.2.1" + # https://circleci.com/docs/2.0/configuration-reference/ + + # Mac/IOS specific examples and docs under the following links: + # https://circleci.com/docs/2.0/hello-world-macos/ + + steps: + - checkout + - run: git submodule sync + - run: git submodule update --init + # Commands will execute in macOS container + # with Xcode 10.2.1 installed + - run: xcodebuild -version + #- run: + # name: get xcodebuild build options + # command: xcodebuild -help + - run: + name: get xcodebuild build settings + command: xcodebuild -showBuildSettings + + - run: + name: force wipe of any pre-existing derived data in CI + command: rm -rf /Users/distiller/Library/Developer/Xcode/DerivedData/NetNewsWire-* + + # Build the app and run tests + - run: + name: Build Mac + command: xcodebuild -workspace NetNewsWire.xcworkspace -scheme NetNewsWire -configuration Debug -showBuildTimingSummary + # NOTE(heckj): + # the -configuration Release build invokes a shell script specifically + # codesigning the Sparkle pieces with the developer 'Brent Simmons', + # so we don't try and invoke that in CI + # + + # the stuff below is from example that was using fastlane + # (and we're not using that...) so it's placeholder tidbits + # to clue me in to where I can get things for test log output + # for the CircleCI UI exposure... + + # Collect XML test results data to show in the UI, + # and save the same XML files under test-results folder + # in the Artifacts tab + #- store_test_results: + # path: test_output/report.xml + #- store_artifacts: + # path: /tmp/test-results + # destination: scan-test-results + #- store_artifacts: + # path: ~/Library/Logs/scan + # destination: scan-logs diff --git a/Appcasts/netnewswire-beta.xml b/Appcasts/netnewswire-beta.xml index 49ec6b42e..691906ae5 100755 --- a/Appcasts/netnewswire-beta.xml +++ b/Appcasts/netnewswire-beta.xml @@ -6,6 +6,58 @@ Most recent NetNewsWire changes with links to updates. en + + NetNewsWire 5.0a3 + Fixed crash happening only on macOS 10.15 beta. We owe Apple a bug report for this one.

+

Fixed a crash that could happen when finding a feed.

+

Skip showing error dialogs on automatic refreshes.

+

Immediately show the refresh progress bar when an OPML import to Feedbin starts.

+

Add ellipsis to Import from OPML and Export to OPML buttons.

+ ]]>
+ Mon, 10 Jun 2019 21:45:00 -0700 + + 10.14.4 +
+ + + + NetNewsWire 5.0a2 + Escape HTML in the title in the article view — if there’s HTML in the title, the tags should actually be displayed.

+

The Mark as Read command in the Article menu now turns into Mark as Unread at the appropriate times.

+

Feedbin syncing: send locally changed statuses before downloading statuses from the server.

+

Feedbin syncing: fix bug renaming a folder that has no feeds.

+

Feedbin syncing: fixed a bunch of accuracy and reliability issues, and a crashing bug.

+

Fixed issue where local account feed finder could lock UI in the case of an error.

+ ]]>
+ Sat, 08 Jun 2019 16:00:00 -0700 + + 10.14.4 +
+ + + + NetNewsWire 5.0a1 + NetNewsWire 5.0 has reached alpha stage! This means it has no known bugs. It surely does have bugs, though. Now it’s time for testing. (And writing the Help book. And making the website better.)

+

Fixed a crashing bug with parsing a response from Feedbin. (Totally our fault, not Feedbin’s fault.)

+

Show avatars from Micro.blog feeds with multiple authors (such as your personal timeline feed).

+

Made OPML import to the On My Mac account way faster.

+

The Today smart feed now updates when the day changes.

+

You can now drag and drop in the sidebar between accounts.

+

Made the default file name for OPML exports “Subscriptions-[accountName].opml”

+

Add explanation text to Account preferences for the Name field. (It’s just a display name and doesn’t affect authentication.)

+

Fixed several bugs with Feedbin syncing — it’s now more reliable. (We know of no remaining sync bugs, though of course there might be some.)

+

Added a placeholder web page for the Help book.

+

New app icon! But it might take a while for your Mac to notice and put in the Dock. (I wish we could speed that up, but it’s out of our control.)

+ ]]>
+ Fri, 31 May 2019 20:30:00 -0700 + + 10.14.4 +
+ + NetNewsWire 5.0d17 Void)? = nil) { + public func refreshAll(completion: @escaping (Result) -> Void) { self.delegate.refreshAll(for: self, completion: completion) } @@ -334,9 +324,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, 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(.success(())) - } + self.delegate.refreshAll(for: self, completion: completion) case .failure(let error): completion(.failure(error)) } @@ -392,16 +380,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return feed } - func addFeed(container: Container, feed: Feed, completion: @escaping (Result) -> Void) { - delegate.addFeed(for: self, to: container, with: feed, completion: completion) + public func addFeed(_ feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { + delegate.addFeed(for: self, with: feed, to: container, completion: completion) } - func removeFeed(_ feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - delegate.removeFeed(for: self, from: container, with: feed, completion: completion) - } - - public func createFeed(url: String, completion: @escaping (Result) -> Void) { - delegate.createFeed(for: self, url: url, completion: completion) + public func createFeed(url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + delegate.createFeed(for: self, url: url, name: name, container: container, completion: completion) } func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed { @@ -411,27 +395,32 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, feed.name = name feed.homePageURL = homePageURL - addFeed(feed) - return feed } - public func deleteFeed(_ feed: Feed, completion: @escaping (Result) -> Void) { - feedMetadata[feed.url] = nil - delegate.deleteFeed(for: self, with: feed, completion: completion) + public func removeFeed(_ feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + delegate.removeFeed(for: self, with: feed, from: container, completion: completion) + } + + public func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + delegate.moveFeed(for: self, with: feed, from: from, to: to, completion: completion) } public func renameFeed(_ feed: Feed, to name: String, completion: @escaping (Result) -> Void) { delegate.renameFeed(for: self, with: feed, to: name, completion: completion) } - public func restoreFeed(_ feed: Feed, folder: Folder?, completion: @escaping (Result) -> Void) { - delegate.restoreFeed(for: self, feed: feed, folder: folder, completion: completion) + public func restoreFeed(_ feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + delegate.restoreFeed(for: self, feed: feed, container: container, completion: completion) } - public func deleteFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { - delegate.deleteFolder(for: self, with: folder, completion: completion) + public func addFolder(_ name: String, completion: @escaping (Result) -> Void) { + delegate.addFolder(for: self, name: name, completion: completion) + } + + public func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { + delegate.removeFolder(for: self, with: folder, completion: completion) } public func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result) -> Void) { @@ -442,6 +431,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.restoreFolder(for: self, folder: folder, completion: completion) } + func clearFeedMetadata(_ feed: Feed) { + feedMetadata[feed.url] = nil + } + func addFolder(_ folder: Folder) { folders!.insert(folder) postChildrenDidChangeNotification() @@ -457,8 +450,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, structureDidChange() DispatchQueue.main.async { - self.refreshAll() + self.refreshAll() { result in } } + } public func updateUnreadCounts(for feeds: Set) { @@ -679,27 +673,25 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return _flattenedFeeds } - public func removeFeed(_ feed: Feed, completion: @escaping (Result) -> Void) { - delegate.removeFeed(for: self, from: self, with: feed, completion: completion) - } - - public func addFeed(_ feed: Feed, completion: @escaping (Result) -> Void) { - delegate.addFeed(for: self, to: self, with: feed, completion: completion) - } - - func removeFeed(_ feed: Feed) { + public func removeFeed(_ feed: Feed) { topLevelFeeds.remove(feed) structureDidChange() postChildrenDidChangeNotification() } - func addFeed(_ feed: Feed) { + public func addFeed(_ feed: Feed) { topLevelFeeds.insert(feed) structureDidChange() postChildrenDidChangeNotification() } - func deleteFolder(_ folder: Folder) { + func addFeedIfNotInAnyFolder(_ feed: Feed) { + if !flattenedFeeds().contains(feed) { + addFeed(feed) + } + } + + func removeFolder(_ folder: Folder) { folders?.remove(folder) structureDidChange() postChildrenDidChangeNotification() @@ -772,19 +764,19 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, @objc func saveToDiskIfNeeded() { - if dirty { + if dirty && !isDeleted { saveToDisk() } } @objc func saveFeedMetadataIfNeeded() { - if feedMetadataDirty { + if feedMetadataDirty && !isDeleted { saveFeedMetadata() } } @objc func saveAccountMetadataIfNeeded() { - if metadataDirty { + if metadataDirty && !isDeleted { saveAccountMetadata() } } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index a86e081b8..4e8d20f58 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; }; 51D5875C227F630B00900287 /* tags_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58759227F630B00900287 /* tags_initial.json */; }; 51D5875E227F643C00900287 /* AccountFolderSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */; }; + 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB40229AF61B00645299 /* AccountError.swift */; }; 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; }; 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; }; @@ -131,6 +132,7 @@ 51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = ""; }; 51D58759227F630B00900287 /* tags_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_initial.json; sourceTree = ""; }; 51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFolderSyncTest.swift; sourceTree = ""; }; + 51E3EB40229AF61B00645299 /* AccountError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountError.swift; sourceTree = ""; }; 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = ""; }; 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = ""; }; @@ -285,6 +287,7 @@ children = ( 848935101F62486800CEBD24 /* Account.swift */, 841974241F6DDCE4006346C4 /* AccountDelegate.swift */, + 51E3EB40229AF61B00645299 /* AccountError.swift */, 846E77531F6F00E300A165E2 /* AccountManager.swift */, 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */, 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */, @@ -530,6 +533,7 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, + 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index aaffefc16..56005273a 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -14,6 +14,7 @@ protocol AccountDelegate { // Local account does not; some synced accounts might. var supportsSubFolders: Bool { get } + var usesTags: Bool { get } var opmlImportInProgress: Bool { get } var server: String? { get } @@ -22,23 +23,23 @@ protocol AccountDelegate { var refreshProgress: DownloadProgress { get } - func refreshAll(for account: Account, completion: (() -> Void)?) + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) - func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) - func createFeed(for account: Account, url: String, completion: @escaping (Result) -> Void) + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) - func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result) -> Void) - - func addFeed(for account: Account, to container: Container, with: Feed, completion: @escaping (Result) -> Void) - func removeFeed(for account: Account, from container: Container, with: Feed, completion: @escaping (Result) -> Void) + func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) + func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) - func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result) -> Void) + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? diff --git a/Frameworks/Account/AccountError.swift b/Frameworks/Account/AccountError.swift new file mode 100644 index 000000000..0aeb109a1 --- /dev/null +++ b/Frameworks/Account/AccountError.swift @@ -0,0 +1,69 @@ +// +// AccountError.swift +// Account +// +// Created by Maurice Parker on 5/26/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +public enum AccountError: LocalizedError { + + case createErrorNotFound + case createErrorAlreadySubscribed + case opmlImportInProgress + case wrappedError(error: Error, account: Account) + + public var errorDescription: String? { + switch self { + case .createErrorNotFound: + return NSLocalizedString("The feed couldn't be found and can't be added.", comment: "Not found") + case .createErrorAlreadySubscribed: + return NSLocalizedString("You are already subscribed to this feed and can't add it again.", comment: "Already subscribed") + case .opmlImportInProgress: + return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running") + case .wrappedError(let error, let account): + switch error { + case TransportError.httpError(let status): + if status == 401 { + let localizedText = NSLocalizedString("Your \"%@\" credentials are invalid or expired.", comment: "Invalid or expired") + return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay) as String + } else { + return unknownError(error, account) + } + default: + return unknownError(error, account) + } + } + } + + public var recoverySuggestion: String? { + switch self { + case .createErrorNotFound: + return nil + case .createErrorAlreadySubscribed: + return nil + case .wrappedError(let error, _): + switch error { + case TransportError.httpError(let status): + if status == 401 { + return NSLocalizedString("Please update your credentials for this account.", comment: "Try later") + } else { + return NSLocalizedString("Please try again later.", comment: "Try later") + } + default: + return NSLocalizedString("Please try again later.", comment: "Try later") + } + default: + return NSLocalizedString("Please try again later.", comment: "Try later") + } + } + + private func unknownError(_ error: Error, _ account: Account) -> String { + let localizedText = NSLocalizedString("An error occurred while processing the \"%@\" account: %@", comment: "Unknown error") + return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String + } + +} diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 48f7de3cb..f707ff6c1 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -21,6 +21,7 @@ public final class AccountManager: UnreadCountProvider { public static let shared = AccountManager() public let defaultAccount: Account + private let accountsFolder = RSDataSubfolder(nil, "Accounts")! private var accountsDictionary = [String: Account]() @@ -126,6 +127,7 @@ public final class AccountManager: UnreadCountProvider { } accountsDictionary.removeValue(forKey: account.accountID) + account.isDeleted = true do { try FileManager.default.removeItem(atPath: account.dataFolder) @@ -145,9 +147,19 @@ public final class AccountManager: UnreadCountProvider { return accountsDictionary[accountID] } - public func refreshAll() { + public func refreshAll(errorHandler: @escaping (Error) -> Void) { - activeAccounts.forEach { $0.refreshAll() } + activeAccounts.forEach { account in + account.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + errorHandler(error) + } + } + } + } public func syncArticleStatusAll(completion: (() -> Void)? = nil) { diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 78afa189d..bd3092894 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -18,6 +18,7 @@ extension Notification.Name { public protocol Container: class { + var account: Account? { get } var topLevelFeeds: Set { get set } var folders: Set? { get set } @@ -27,8 +28,8 @@ public protocol Container: class { func hasChildFolder(with: String) -> Bool func childFolder(with: String) -> Folder? - func removeFeed(_ feed: Feed, completion: @escaping (Result) -> Void) - func addFeed(_ feed: Feed, completion: @escaping (Result) -> Void) + func removeFeed(_ feed: Feed) + func addFeed(_ feed: Feed) //Recursive — checks subfolders func flattenedFeeds() -> Set diff --git a/Frameworks/Account/Feed.swift b/Frameworks/Account/Feed.swift index 5b416bcf8..c0b3cb9cd 100644 --- a/Frameworks/Account/Feed.swift +++ b/Frameworks/Account/Feed.swift @@ -64,7 +64,7 @@ public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, Ha set { let oldNameForDisplay = nameForDisplay metadata.name = newValue - if oldNameForDisplay != nameForDisplay { + if oldNameForDisplay != newValue { postDisplayNameDidChangeNotification() } } diff --git a/Frameworks/Account/FeedFinder/FeedFinder.swift b/Frameworks/Account/FeedFinder/FeedFinder.swift index 89ef24602..c6c2f660e 100644 --- a/Frameworks/Account/FeedFinder/FeedFinder.swift +++ b/Frameworks/Account/FeedFinder/FeedFinder.swift @@ -11,38 +11,54 @@ import RSParser import RSWeb import RSCore -protocol FeedFinderDelegate: class { - - func feedFinder(_: FeedFinder, didFindFeeds: Set) -} - class FeedFinder { + + static func find(url: URL, completion: @escaping (Result, Error>) -> Void) { - private weak var delegate: FeedFinderDelegate? - private var feedSpecifiers = [String: FeedSpecifier]() - private var didNotifyDelegate = false - - var initialDownloadError: Error? - var initialDownloadStatusCode = -1 - - init(url: URL, delegate: FeedFinderDelegate) { - - self.delegate = delegate - - DispatchQueue.main.async() { () -> Void in - - self.findFeeds(url) + downloadUsingCache(url) { (data, response, error) in + + if response?.forcedStatusCode == 404 { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data, let response = response else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if !response.statusIsOK || data.isEmpty { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if FeedFinder.isFeed(data, url.absoluteString) { + let feedSpecifier = FeedSpecifier(title: nil, urlString: url.absoluteString, source: .UserEntered) + completion(.success(Set([feedSpecifier]))) + return + } + + if !FeedFinder.isHTML(data) { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion) + } + } - deinit { - notifyDelegateIfNeeded() - } } private extension FeedFinder { - func addFeedSpecifier(_ feedSpecifier: FeedSpecifier) { + static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) { // If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source. @@ -55,7 +71,7 @@ private extension FeedFinder { } } - func findFeedsInHTMLPage(htmlData: Data, urlString: String) { + static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result, Error>) -> Void) { // Feeds in the section we automatically assume are feeds. // If there are none from the section, @@ -63,31 +79,35 @@ private extension FeedFinder { // and added once we determine they are feeds. let possibleFeedSpecifiers = possibleFeedsInHTMLPage(htmlData: htmlData, urlString: urlString) + var feedSpecifiers = [String: FeedSpecifier]() var feedSpecifiersToDownload = Set() var didFindFeedInHTMLHead = false for oneFeedSpecifier in possibleFeedSpecifiers { if oneFeedSpecifier.source == .HTMLHead { - addFeedSpecifier(oneFeedSpecifier) + addFeedSpecifier(oneFeedSpecifier, feedSpecifiers: &feedSpecifiers) didFindFeedInHTMLHead = true } else { - if !feedSpecifiersContainsURLString(oneFeedSpecifier.urlString) { + if feedSpecifiers[oneFeedSpecifier.urlString] == nil { feedSpecifiersToDownload.insert(oneFeedSpecifier) } } } - if didFindFeedInHTMLHead || feedSpecifiersToDownload.isEmpty { - stopFinding() - } - else { - downloadFeedSpecifiers(feedSpecifiersToDownload) + if didFindFeedInHTMLHead { + completion(.success(Set(feedSpecifiers.values))) + return + } else if feedSpecifiersToDownload.isEmpty { + completion(.failure(AccountError.createErrorNotFound)) + return + } else { + downloadFeedSpecifiers(feedSpecifiersToDownload, feedSpecifiers: feedSpecifiers, completion: completion) } } - func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set { + static func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set { let parserData = ParserData(url: urlString, data: htmlData) var feedSpecifiers = HTMLFeedFinder(parserData: parserData).feedSpecifiers @@ -109,105 +129,42 @@ private extension FeedFinder { return feedSpecifiers } - func feedSpecifiersContainsURLString(_ urlString: String) -> Bool { - - if let _ = feedSpecifiers[urlString] { - return true - } - return false - } - - func isHTML(_ data: Data) -> Bool { - + static func isHTML(_ data: Data) -> Bool { return (data as NSData).rs_dataIsProbablyHTML() } - func findFeeds(_ initialURL: URL) { + static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result, Error>) -> Void) { - downloadInitialFeed(initialURL) - } + var resultFeedSpecifiers = feedSpecifiers + let group = DispatchGroup() + + for downloadFeedSpecifier in downloadFeedSpecifiers { - func downloadInitialFeed(_ initialURL: URL) { - - downloadUsingCache(initialURL) { (data, response, error) in - - self.initialDownloadStatusCode = response?.forcedStatusCode ?? -1 - - if let error = error { - self.initialDownloadError = error - self.stopFinding() - return - } - guard let data = data, let response = response else { - self.stopFinding() - return - } - - if !response.statusIsOK || data.isEmpty { - self.stopFinding() - return - } - - if self.isFeed(data, initialURL.absoluteString) { - let feedSpecifier = FeedSpecifier(title: nil, urlString: initialURL.absoluteString, source: .UserEntered) - self.addFeedSpecifier(feedSpecifier) - self.stopFinding() - return - } - - if !self.isHTML(data) { - self.stopFinding() - return - } - - self.findFeedsInHTMLPage(htmlData: data, urlString: initialURL.absoluteString) - } - } - - func downloadFeedSpecifiers(_ feedSpecifiers: Set) { - - var pendingDownloads = feedSpecifiers - - for oneFeedSpecifier in feedSpecifiers { - - guard let url = URL(string: oneFeedSpecifier.urlString) else { - pendingDownloads.remove(oneFeedSpecifier) + guard let url = URL(string: downloadFeedSpecifier.urlString) else { continue } - + + group.enter() downloadUsingCache(url) { (data, response, error) in - - pendingDownloads.remove(oneFeedSpecifier) - if let data = data, let response = response, response.statusIsOK, error == nil { - if self.isFeed(data, oneFeedSpecifier.urlString) { - self.addFeedSpecifier(oneFeedSpecifier) + if self.isFeed(data, downloadFeedSpecifier.urlString) { + addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers) } } - - if pendingDownloads.isEmpty { - self.stopFinding() - } + group.leave() } + } - } - func stopFinding() { - - notifyDelegateIfNeeded() - } - - func notifyDelegateIfNeeded() { - - if !didNotifyDelegate { - delegate?.feedFinder(self, didFindFeeds: Set(feedSpecifiers.values)) - didNotifyDelegate = true + group.notify(queue: DispatchQueue.main) { + completion(.success(Set(resultFeedSpecifiers.values))) } + } - func isFeed(_ data: Data, _ urlString: String) -> Bool { - + static func isFeed(_ data: Data, _ urlString: String) -> Bool { let parserData = ParserData(url: urlString, data: data) return FeedParser.canParse(parserData) } + } diff --git a/Frameworks/Account/FeedFinder/FeedSpecifier.swift b/Frameworks/Account/FeedFinder/FeedSpecifier.swift index 68073198e..f90e956fe 100644 --- a/Frameworks/Account/FeedFinder/FeedSpecifier.swift +++ b/Frameworks/Account/FeedFinder/FeedSpecifier.swift @@ -46,7 +46,7 @@ struct FeedSpecifier: Hashable { return feedSpecifiers.anyObject() } - var currentHighScore = 0 + var currentHighScore = Int.min var currentBestFeed: FeedSpecifier? = nil for oneFeedSpecifier in feedSpecifiers { diff --git a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift index 86072b688..27282de5b 100644 --- a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift +++ b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift @@ -143,25 +143,6 @@ final class FeedbinAPICaller: NSObject { transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion) } - func deleteTag(name: String, completion: @escaping (Result<[FeedbinTagging]?, Error>) -> Void) { - - let callURL = feedbinBaseURL.appendingPathComponent("tags.json") - let request = URLRequest(url: callURL, credentials: credentials) - let payload = FeedbinDeleteTag(name: name) - - transport.send(request: request, method: HTTPMethod.delete, payload: payload, resultType: [FeedbinTagging].self) { result in - - switch result { - case .success(let (_, taggings)): - completion(.success(taggings)) - case .failure(let error): - completion(.failure(error)) - } - - } - - } - func retrieveSubscriptions(completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) { let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json") @@ -358,9 +339,9 @@ final class FeedbinAPICaller: NSObject { let concatIDs = articleIDs.reduce("") { param, articleID in return param + ",\(articleID)" } let paramIDs = String(concatIDs.dropFirst()) - var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! - callURL.queryItems = [URLQueryItem(name: "ids", value: paramIDs)] - let request = URLRequest(url: callURL.url!, credentials: credentials) + var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "ids", value: paramIDs), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) transport.send(request: request, resultType: [FeedbinEntry].self) { result in @@ -380,9 +361,9 @@ final class FeedbinAPICaller: NSObject { let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() let sinceString = FeedbinDate.formatter.string(from: since) - var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("/feeds/\(feedID)/entries.json"), resolvingAgainstBaseURL: false)! - callURL.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100")] - let request = URLRequest(url: callURL.url!, credentials: credentials) + var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("feeds/\(feedID)/entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) transport.send(request: request, resultType: [FeedbinEntry].self) { result in @@ -411,9 +392,9 @@ final class FeedbinAPICaller: NSObject { }() let sinceString = FeedbinDate.formatter.string(from: since) - var callURL = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! - callURL.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100")] - let request = URLRequest(url: callURL.url!, credentials: credentials) + var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) transport.send(request: request, resultType: [FeedbinEntry].self) { result in @@ -438,12 +419,12 @@ final class FeedbinAPICaller: NSObject { func retrieveEntries(page: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) { - guard let callURL = URL(string: page) else { + guard let url = URL(string: page) else { completion(.success((nil, nil))) return } - let request = URLRequest(url: callURL, credentials: credentials) + let request = URLRequest(url: url, credentials: credentials) transport.send(request: request, resultType: [FeedbinEntry].self) { result in @@ -550,11 +531,12 @@ extension FeedbinAPICaller { } if let lowerBound = link.range(of: "page=")?.upperBound { - if let upperBound = link.range(of: "&")?.lowerBound { - return Int(link[lowerBound..")?.lowerBound { - return Int(link[lowerBound..") { + return Int(partialLink[partialLink.startIndex.. Void)? = nil) { + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) @@ -87,11 +88,13 @@ final class FeedbinAccountDelegate: AccountDelegate { case .success(): self.refreshArticles(account) { - self.refreshArticleStatus(for: account) { - self.refreshMissingArticles(account) { - self.refreshProgress.clear() - DispatchQueue.main.async { - completion?() + self.sendArticleStatus(for: account) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + self.refreshProgress.clear() + DispatchQueue.main.async { + completion(.success(())) + } } } } @@ -99,9 +102,9 @@ final class FeedbinAccountDelegate: AccountDelegate { case .failure(let error): DispatchQueue.main.async { - completion?() self.refreshProgress.clear() - self.handleError(error) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } @@ -205,12 +208,14 @@ final class FeedbinAccountDelegate: AccountDelegate { os_log(.debug, log: log, "Begin importing OPML...") opmlImportInProgress = true + refreshProgress.addToNumberOfTasksAndRemaining(1) caller.importOPML(opmlData: opmlData) { result in switch result { case .success(let importResult): if importResult.complete { os_log(.debug, log: self.log, "Import OPML done.") + self.refreshProgress.completeTask() self.opmlImportInProgress = false DispatchQueue.main.async { completion(.success(())) @@ -220,77 +225,118 @@ final class FeedbinAccountDelegate: AccountDelegate { } case .failure(let error): os_log(.debug, log: self.log, "Import OPML failed.") + self.refreshProgress.completeTask() self.opmlImportInProgress = false DispatchQueue.main.async { - completion(.failure(error)) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } } } + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + if let folder = account.ensureFolder(with: name) { + completion(.success(folder)) + } else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + } + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + guard folder.hasAtLeastOneFeed() else { + folder.name = name + return + } + caller.renameTag(oldName: folder.name ?? "", newName: name) { result in switch result { case .success: DispatchQueue.main.async { + self.renameFolderRelationship(for: account, fromName: folder.name ?? "", toName: name) folder.name = name completion(.success(())) } case .failure(let error): DispatchQueue.main.async { - completion(.failure(error)) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } } } - func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { // Feedbin uses tags and if at least one feed isn't tagged, then the folder doesn't exist on their system guard folder.hasAtLeastOneFeed() else { - account.deleteFolder(folder) + account.removeFolder(folder) return } - // After we successfully delete at Feedbin, we add all the feeds to the account to save them. We then - // delete the folder. We then sync the taggings we received on the delete to remove any feeds from - // the account that might be in another folder. - caller.deleteTag(name: folder.name ?? "") { result in - switch result { - case .success(let taggings): - DispatchQueue.main.sync { - BatchUpdate.shared.perform { - for feed in folder.topLevelFeeds { - account.addFeed(feed) - self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + let group = DispatchGroup() + + for feed in folder.topLevelFeeds { + + if feed.folderRelationship?.count ?? 0 > 1 { + + if let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] { + group.enter() + caller.deleteTagging(taggingID: feedTaggingID) { result in + group.leave() + switch result { + case .success: + DispatchQueue.main.async { + self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + case .failure(let error): + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) } - account.deleteFolder(folder) } - completion(.success(())) } - self.syncTaggings(account, taggings) - case .failure(let error): - DispatchQueue.main.async { - completion(.failure(error)) + + } else { + + if let subscriptionID = feed.subscriptionID { + group.enter() + caller.deleteSubscription(subscriptionID: subscriptionID) { result in + group.leave() + switch result { + case .success: + DispatchQueue.main.async { + account.clearFeedMetadata(feed) + } + case .failure(let error): + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) + } + } + } + } + + } + + group.notify(queue: DispatchQueue.main) { + account.removeFolder(folder) + completion(.success(())) } } - func createFeed(for account: Account, url: String, completion: @escaping (Result) -> Void) { + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { caller.createSubscription(url: url) { result in switch result { case .success(let subResult): switch subResult { case .created(let subscription): - self.createFeed(account: account, subscription: subscription, completion: completion) + self.createFeed(account: account, subscription: subscription, name: name, container: container, completion: completion) case .multipleChoice(let choices): - self.decideBestFeedChoice(account: account, url: url, choices: choices, completion: completion) + self.decideBestFeedChoice(account: account, url: url, name: name, container: container, choices: choices, completion: completion) case .alreadySubscribed: DispatchQueue.main.async { completion(.failure(AccountError.createErrorAlreadySubscribed)) @@ -302,7 +348,8 @@ final class FeedbinAccountDelegate: AccountDelegate { } case .failure(let error): DispatchQueue.main.async { - completion(.failure(error)) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } @@ -327,43 +374,38 @@ final class FeedbinAccountDelegate: AccountDelegate { } case .failure(let error): DispatchQueue.main.async { - completion(.failure(error)) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } } } - func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result) -> Void) { - - // This error should never happen - guard let subscriptionID = feed.subscriptionID else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) - return + func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + if feed.folderRelationship?.count ?? 0 > 1 { + deleteTagging(for: account, with: feed, from: container, completion: completion) + } else { + deleteSubscription(for: account, with: feed, from: container, completion: completion) } - - caller.deleteSubscription(subscriptionID: subscriptionID) { result in - switch result { - case .success: - DispatchQueue.main.async { - account.removeFeed(feed) - if let folders = account.folders { - for folder in folders { - folder.removeFeed(feed) - } - } - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { + } + + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + if from is Account { + addFeed(for: account, with: feed, to: to, completion: completion) + } else { + deleteTagging(for: account, with: feed, from: from) { result in + switch result { + case .success: + self.addFeed(for: account, with: feed, to: to, completion: completion) + case .failure(let error): completion(.failure(error)) } } } - } - - func addFeed(for account: Account, to container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { + + func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { if let folder = container as? Folder, let feedID = Int(feed.feedID) { caller.createTagging(feedID: feedID, name: folder.name ?? "") { result in @@ -377,56 +419,39 @@ final class FeedbinAccountDelegate: AccountDelegate { } case .failure(let error): DispatchQueue.main.async { - completion(.failure(error)) + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) } } } } else { - if let account = container as? Account { - account.addFeed(feed) - } DispatchQueue.main.async { + if let account = container as? Account { + account.addFeedIfNotInAnyFolder(feed) + } completion(.success(())) } } } - func removeFeed(for account: Account, from container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { - - if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] { - caller.deleteTagging(taggingID: feedTaggingID) { result in + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + + if let existingFeed = account.existingFeed(withURL: feed.url) { + account.addFeed(existingFeed, to: container) { result in switch result { case .success: - DispatchQueue.main.async { - folder.removeFeed(feed) - completion(.success(())) - } + completion(.success(())) case .failure(let error): - DispatchQueue.main.async { - completion(.failure(error)) - } + completion(.failure(error)) } } } else { - if let account = container as? Account { - account.removeFeed(feed) - } - completion(.success(())) - } - - } - - func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result) -> Void) { - - let editedName = feed.editedName - - createFeed(for: account, url: feed.url) { result in - switch result { - case .success(let feed): - self.processRestoredFeed(for: account, feed: feed, editedName: editedName, folder: folder, completion: completion) - case .failure(let error): - DispatchQueue.main.async { + createFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): completion(.failure(error)) } } @@ -436,22 +461,27 @@ final class FeedbinAccountDelegate: AccountDelegate { func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { - account.addFolder(folder) let group = DispatchGroup() for feed in folder.topLevelFeeds { + folder.topLevelFeeds.remove(feed) + group.enter() - addFeed(for: account, to: folder, with: feed) { result in - if account.topLevelFeeds.contains(feed) { - account.removeFeed(feed) - } + restoreFeed(for: account, feed: feed, container: folder) { result in group.leave() + switch result { + case .success: + break + case .failure(let error): + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } } } group.notify(queue: DispatchQueue.main) { + account.addFolder(folder) completion(.success(())) } @@ -464,6 +494,10 @@ final class FeedbinAccountDelegate: AccountDelegate { } database.insertStatuses(syncStatuses) + if database.selectPendingCount() > 100 { + sendArticleStatus(for: account) {} + } + return account.update(articles, statusKey: statusKey, flag: flag) } @@ -491,14 +525,6 @@ final class FeedbinAccountDelegate: AccountDelegate { private extension FeedbinAccountDelegate { - func handleError(_ error: Error) { - #if os(macOS) - NSApplication.shared.presentError(error) - #else - UIApplication.shared.presentError(error) - #endif - } - func refreshAccount(_ account: Account, completion: @escaping (Result) -> Void) { caller.retrieveTags { result in @@ -530,6 +556,7 @@ private extension FeedbinAccountDelegate { if let result = importResult, result.complete { os_log(.debug, log: self.log, "Checking status of OPML import successfully completed.") timer.invalidate() + self.refreshProgress.completeTask() self.opmlImportInProgress = false DispatchQueue.main.async { completion(.success(())) @@ -538,6 +565,7 @@ private extension FeedbinAccountDelegate { case .failure(let error): os_log(.debug, log: self.log, "Import OPML check failed.") timer.invalidate() + self.refreshProgress.completeTask() self.opmlImportInProgress = false DispatchQueue.main.async { completion(.failure(error)) @@ -568,7 +596,7 @@ private extension FeedbinAccountDelegate { account.addFeed(feed) clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") } - account.deleteFolder(folder) + account.removeFolder(folder) } } } @@ -675,7 +703,10 @@ private extension FeedbinAccountDelegate { DispatchQueue.main.sync { if let feed = account.idToFeedDictionary[subFeedId] { feed.name = subscription.name + // If the name has been changed on the server remove the locally edited name + feed.editedName = nil feed.homePageURL = subscription.homePageURL + feed.subscriptionID = String(subscription.subscriptionID) } else { let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL) feed.subscriptionID = String(subscription.subscriptionID) @@ -820,72 +851,15 @@ private extension FeedbinAccountDelegate { } - func processRestoredFeed(for account: Account, feed: Feed, editedName: String?, folder: Folder?, completion: @escaping (Result) -> Void) { - - if let folder = folder { - - addFeed(for: account, to: folder, with: feed) { result in - - switch result { - case .success: - - if editedName != nil { - DispatchQueue.main.async { - account.removeFeed(feed) - folder.addFeed(feed) - } - self.processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion) - } else { - DispatchQueue.main.async { - account.removeFeed(feed) - folder.addFeed(feed) - completion(.success(())) - } - } - - case .failure(let error): - DispatchQueue.main.async { - completion(.failure(error)) - } - } - + func renameFolderRelationship(for account: Account, fromName: String, toName: String) { + for feed in account.flattenedFeeds() { + if var folderRelationship = feed.folderRelationship { + let relationship = folderRelationship[fromName] + folderRelationship[fromName] = nil + folderRelationship[toName] = relationship + feed.folderRelationship = folderRelationship } - - } else { - - DispatchQueue.main.async { - account.addFeed(feed) - } - - if editedName != nil { - processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion) - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - - } - - func processRestoredFeedName(for account: Account, feed: Feed, editedName: String, completion: @escaping (Result) -> Void) { - - renameFeed(for: account, with: feed, to: editedName) { result in - switch result { - case .success: - DispatchQueue.main.async { - feed.editedName = editedName - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { - completion(.failure(error)) - } - } - - } - } func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) { @@ -904,7 +878,7 @@ private extension FeedbinAccountDelegate { } } - func decideBestFeedChoice(account: Account, url: String, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result) -> Void) { + func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result) -> Void) { let feedSpecifiers: [FeedSpecifier] = choices.map { choice in let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink @@ -914,7 +888,7 @@ private extension FeedbinAccountDelegate { if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) { if let bestSubscription = choices.filter({ bestSpecifier.urlString == $0.url }).first { - createFeed(for: account, url: bestSubscription.url, completion: completion) + createFeed(for: account, url: bestSubscription.url, name: name, container: container, completion: completion) } else { DispatchQueue.main.async { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) @@ -928,44 +902,66 @@ private extension FeedbinAccountDelegate { } - func createFeed( account: Account, subscription sub: FeedbinSubscription, completion: @escaping (Result) -> Void) { + func createFeed( account: Account, subscription sub: FeedbinSubscription, name: String?, container: Container, completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL) feed.subscriptionID = String(sub.subscriptionID) - // Download the initial articles - self.caller.retrieveEntries(feedID: feed.feedID) { result in - + account.addFeed(feed, to: container) { result in switch result { - case .success(let (entries, page)): - - self.processEntries(account: account, entries: entries) { - self.refreshArticles(account, page: page) { - self.refreshArticleStatus(for: account) { - self.refreshMissingArticles(account) { - DispatchQueue.main.async { - completion(.success(feed)) - } - } + case .success: + if let name = name { + account.renameFeed(feed, to: name) { result in + switch result { + case .success: + self.initialFeedDownload(account: account, feed: feed, completion: completion) + case .failure(let error): + completion(.failure(error)) } } + } else { + self.initialFeedDownload(account: account, feed: feed, completion: completion) } - case .failure(let error): - os_log(.error, log: self.log, "Initial articles download failed: %@.", error.localizedDescription) - DispatchQueue.main.async { - completion(.success(feed)) - } + completion(.failure(error)) } - } - + } } + func initialFeedDownload( account: Account, feed: Feed, completion: @escaping (Result) -> Void) { + + // Download the initial articles + self.caller.retrieveEntries(feedID: feed.feedID) { result in + + switch result { + case .success(let (entries, page)): + + self.processEntries(account: account, entries: entries) { + self.refreshArticles(account, page: page) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + DispatchQueue.main.async { + completion(.success(feed)) + } + } + } + } + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + func refreshArticles(_ account: Account, completion: @escaping (() -> Void)) { os_log(.debug, log: log, "Refreshing articles...") @@ -1098,7 +1094,7 @@ private extension FeedbinAccountDelegate { } let parsedItems: [ParsedItem] = entries.map { entry in - let authors = Set([ParsedAuthor(name: entry.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) + let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: nil, externalURL: entry.url, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: authors, tags: nil, attachments: nil) } @@ -1122,13 +1118,6 @@ private extension FeedbinAccountDelegate { _ = account.update(markUnreadArticles, statusKey: .read, flag: false) } - // Mark articles as read - let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) - let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) - DispatchQueue.main.async { - _ = account.update(markReadArticles, statusKey: .read, flag: true) - } - // Save any unread statuses for articles we haven't yet received let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) @@ -1138,6 +1127,22 @@ private extension FeedbinAccountDelegate { } } + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) + let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) + DispatchQueue.main.async { + _ = account.update(markReadArticles, statusKey: .read, flag: true) + } + + // Save any read statuses for articles we haven't yet received + let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) + let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) + if !missingReadArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingReadArticleIDs, .read, true) + } + } + } func syncArticleStarredState(account: Account, articleIDs: [Int]?) { @@ -1156,13 +1161,6 @@ private extension FeedbinAccountDelegate { _ = account.update(markStarredArticles, statusKey: .starred, flag: true) } - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) - let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) - DispatchQueue.main.async { - _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) - } - // Save any starred statuses for articles we haven't yet received let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) @@ -1172,6 +1170,81 @@ private extension FeedbinAccountDelegate { } } + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) + let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) + DispatchQueue.main.async { + _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) + } + + // Save any unstarred statuses for articles we haven't yet received + let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) + let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) + if !missingUnstarredArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingUnstarredArticleIDs, .starred, false) + } + } + + } + + func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + + if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] { + caller.deleteTagging(taggingID: feedTaggingID) { result in + switch result { + case .success: + DispatchQueue.main.async { + self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + folder.removeFeed(feed) + account.addFeedIfNotInAnyFolder(feed) + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } else { + if let account = container as? Account { + account.removeFeed(feed) + } + completion(.success(())) + } + + } + + func deleteSubscription(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + + // This error should never happen + guard let subscriptionID = feed.subscriptionID else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + return + } + + caller.deleteSubscription(subscriptionID: subscriptionID) { result in + switch result { + case .success: + DispatchQueue.main.async { + account.clearFeedMetadata(feed) + account.removeFeed(feed) + if let folders = account.folders { + for folder in folders { + folder.removeFeed(feed) + } + } + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } } diff --git a/Frameworks/Account/Feedbin/FeedbinEntry.swift b/Frameworks/Account/Feedbin/FeedbinEntry.swift index 2a604766e..8d73a51da 100644 --- a/Frameworks/Account/Feedbin/FeedbinEntry.swift +++ b/Frameworks/Account/Feedbin/FeedbinEntry.swift @@ -21,6 +21,7 @@ struct FeedbinEntry: Codable { let summary: String? let datePublished: String? let dateArrived: String? + let jsonFeed: FeedbinEntryJSONFeed? enum CodingKeys: String, CodingKey { case articleID = "id" @@ -32,6 +33,7 @@ struct FeedbinEntry: Codable { case summary = "summary" case datePublished = "published" case dateArrived = "created_at" + case jsonFeed = "json_feed" } // Feedbin dates can't be decoded by the JSONDecoding 8601 decoding strategy. Feedbin @@ -47,3 +49,19 @@ struct FeedbinEntry: Codable { } } + +struct FeedbinEntryJSONFeed: Codable { + let jsonFeedAuthor: FeedbinEntryJSONFeedAuthor? + enum CodingKeys: String, CodingKey { + case jsonFeedAuthor = "author" + } +} + +struct FeedbinEntryJSONFeedAuthor: Codable { + let url: String? + let avatarURL: String? + enum CodingKeys: String, CodingKey { + case url = "url" + case avatarURL = "avatar" + } +} diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index 5bf961fb1..f2ee0551f 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -95,20 +95,12 @@ public final class Folder: DisplayNameProvider, Renamable, Container, UnreadCoun return topLevelFeeds.contains(feed) } - public func addFeed(_ feed: Feed, completion: @escaping (Result) -> Void) { - account?.addFeed(container: self, feed: feed, completion: completion) - } - - public func removeFeed(_ feed: Feed, completion: @escaping (Result) -> Void) { - account?.removeFeed(feed, from: self, completion: completion) - } - - func addFeed(_ feed: Feed) { + public func addFeed(_ feed: Feed) { topLevelFeeds.insert(feed) postChildrenDidChangeNotification() } - func removeFeed(_ feed: Feed) { + public func removeFeed(_ feed: Feed) { topLevelFeeds.remove(feed) postChildrenDidChangeNotification() } diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index 3e9a97d81..aef678300 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -7,6 +7,7 @@ // import Foundation +import RSCore import RSParser import Articles import RSWeb @@ -18,16 +19,13 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate { let supportsSubFolders = false + let usesTags = false let opmlImportInProgress = false let server: String? = nil var credentials: Credentials? var accountMetadata: AccountMetadata? - private weak var account: Account? - private var feedFinder: FeedFinder? - private var createFeedCompletion: ((Result) -> Void)? - private let refresher = LocalAccountRefresher() var refreshProgress: DownloadProgress { @@ -35,9 +33,9 @@ final class LocalAccountDelegate: AccountDelegate { } // LocalAccountDelegate doesn't wait for completion before calling the completion block - func refreshAll(for account: Account, completion: (() -> Void)? = nil) { + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refresher.refreshFeeds(account.flattenedFeeds()) - completion?() + completion(.success(())) } func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { @@ -81,33 +79,57 @@ final class LocalAccountDelegate: AccountDelegate { // We use the same mechanism to load local accounts as we do to load the subscription // OPML all accounts. - account.loadOPML(loadDocument) + BatchUpdate.shared.perform { + account.loadOPML(loadDocument) + } completion(.success(())) } - - func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { - folder.name = name - completion(.success(())) - } - func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - account.deleteFolder(folder) - completion(.success(())) - } - - func createFeed(for account: Account, url urlString: String, completion: @escaping (Result) -> Void) { + func createFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { guard let url = URL(string: urlString) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - self.account = account - createFeedCompletion = completion - - feedFinder = FeedFinder(url: url, delegate: self) - + FeedFinder.find(url: url) { result in + + switch result { + case .success(let feedSpecifiers): + + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), + let url = URL(string: bestFeedSpecifier.urlString) else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if account.hasFeed(withURL: bestFeedSpecifier.urlString) { + completion(.failure(AccountError.createErrorAlreadySubscribed)) + return + } + + let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) + + InitialFeedDownloader.download(url) { parsedFeed in + + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed, {}) + } + + feed.editedName = name + + container.addFeed(feed) + completion(.success(feed)) + + } + + case .failure: + completion(.failure(AccountError.createErrorNotFound)) + } + + } + } func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { @@ -115,54 +137,42 @@ final class LocalAccountDelegate: AccountDelegate { completion(.success(())) } - func deleteFeed(for account: Account, from container: Container, feed: Feed, completion: @escaping (Result) -> Void) { - - if let account = container as? Account { - account.removeFeed(feed) - } - if let folder = container as? Folder { - folder.removeFeed(feed) - } - completion(.success(())) - - } - - func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result) -> Void) { - account.removeFeed(feed) - if let folders = account.folders { - for folder in folders { - folder.removeFeed(feed) - } - } + func removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + container?.removeFeed(feed) completion(.success(())) } - func addFeed(for account: Account, to container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { - if let account = container as? Account { - account.addFeed(feed) - } - if let folder = container as? Folder { - folder.addFeed(feed) - } + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + from.removeFeed(feed) + to.addFeed(feed) completion(.success(())) } - func removeFeed(for account: Account, from container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { - if let account = container as? Account { - account.removeFeed(feed) - } - if let folder = container as? Folder { - folder.removeFeed(feed) - } + func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { + container.addFeed(feed) completion(.success(())) } - func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result) -> Void) { - if let folder = folder { - folder.addFeed(feed) + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + container.addFeed(feed) + completion(.success(())) + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + if let folder = account.ensureFolder(with: name) { + completion(.success(folder)) } else { - account.addFeed(feed) + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) } + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + folder.name = name + completion(.success(())) + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + account.removeFolder(folder) completion(.success(())) } @@ -183,42 +193,3 @@ final class LocalAccountDelegate: AccountDelegate { } } - -extension LocalAccountDelegate: FeedFinderDelegate { - - // MARK: FeedFinderDelegate - - public func feedFinder(_ feedFinder: FeedFinder, didFindFeeds feedSpecifiers: Set) { - - if let error = feedFinder.initialDownloadError { - if feedFinder.initialDownloadStatusCode == 404 { - createFeedCompletion!(.failure(AccountError.createErrorNotFound)) - } else { - createFeedCompletion!(.failure(error)) - } - return - } - - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), - let url = URL(string: bestFeedSpecifier.urlString), - let account = account else { - createFeedCompletion!(.failure(AccountError.createErrorNotFound)) - return - } - - if account.hasFeed(withURL: bestFeedSpecifier.urlString) { - createFeedCompletion!(.failure(AccountError.createErrorAlreadySubscribed)) - return - } - - let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) - InitialFeedDownloader.download(url) { [weak self] parsedFeed in - if let parsedFeed = parsedFeed { - account.update(feed, with: parsedFeed, {}) - } - self?.createFeedCompletion!(.success(feed)) - } - - } - -} diff --git a/Frameworks/SyncDatabase/SyncDatabase.swift b/Frameworks/SyncDatabase/SyncDatabase.swift index a2355c881..39a781391 100644 --- a/Frameworks/SyncDatabase/SyncDatabase.swift +++ b/Frameworks/SyncDatabase/SyncDatabase.swift @@ -31,6 +31,10 @@ public final class SyncDatabase { return syncStatusTable.selectForProcessing() } + public func selectPendingCount() -> Int { + return syncStatusTable.selectPendingCount() + } + public func resetSelectedForProcessing(_ articleIDs: [String]) { syncStatusTable.resetSelectedForProcessing(articleIDs) } diff --git a/Frameworks/SyncDatabase/SyncStatusTable.swift b/Frameworks/SyncDatabase/SyncStatusTable.swift index cef4c80da..63a2036fe 100644 --- a/Frameworks/SyncDatabase/SyncStatusTable.swift +++ b/Frameworks/SyncDatabase/SyncStatusTable.swift @@ -39,6 +39,23 @@ final class SyncStatusTable: DatabaseTable { } + func selectPendingCount() -> Int { + + var count: Int = 0 + + self.queue.fetchSync { (database) in + let sql = "select count(*) from syncStatus" + if let resultSet = database.executeQuery(sql, withArgumentsIn: nil) { + resultSet.next() + count = Int(resultSet.int(forColumnIndex: 0)) + } + + } + + return count + + } + func resetSelectedForProcessing(_ articleIDs: [String]) { self.queue.update { database in let parameters = articleIDs.map { $0 as AnyObject } diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index dc6a95206..269591996 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -334,7 +334,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, @IBAction func refreshAll(_ sender: Any?) { - AccountManager.shared.refreshAll() + AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present) } @IBAction func showAddFeedWindow(_ sender: Any?) { diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index 875c10de2..fe81915b1 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -85,20 +85,20 @@ - + - + - + @@ -413,7 +413,7 @@ - + diff --git a/Mac/Base.lproj/Preferences.storyboard b/Mac/Base.lproj/Preferences.storyboard index 69ab33c6f..ef3c88eb7 100644 --- a/Mac/Base.lproj/Preferences.storyboard +++ b/Mac/Base.lproj/Preferences.storyboard @@ -257,20 +257,20 @@ - + - - - + + + - + @@ -284,17 +284,19 @@ - + - + - + + + + - - - + + @@ -302,6 +304,13 @@ + + + + + + + diff --git a/Mac/ErrorHandler.swift b/Mac/ErrorHandler.swift new file mode 100644 index 000000000..018da1a63 --- /dev/null +++ b/Mac/ErrorHandler.swift @@ -0,0 +1,25 @@ +// +// ErrorHandler.swift +// NetNewsWire +// +// Created by Maurice Parker on 5/26/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import os.log + +struct ErrorHandler { + + private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Account") + + public static func present(_ error: Error) { + NSApplication.shared.presentError(error) + } + + public static func log(_ error: Error) { + os_log(.error, log: self.log, "%@", error.localizedDescription) + } + +} diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 8b1babdbe..3760dbe88 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -52,7 +52,6 @@ class AddFeedController: AddFeedWindowControllerDelegate { return } let account = accountAndFolderSpecifier.account - let folder = accountAndFolderSpecifier.folder if account.hasFeed(withURL: url.absoluteString) { showAlreadySubscribedError(url.absoluteString) @@ -61,20 +60,23 @@ class AddFeedController: AddFeedWindowControllerDelegate { BatchUpdate.shared.start() - account.createFeed(url: url.absoluteString) { [weak self] result in + account.createFeed(url: url.absoluteString, name: title, container: container) { result in - self?.endShowingProgress() + DispatchQueue.main.async { + self.endShowingProgress() + } + BatchUpdate.shared.end() + switch result { case .success(let feed): - self?.processFeed(feed, account: account, folder: folder, url: url, title: title) + NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed]) case .failure(let error): - BatchUpdate.shared.end() switch error { case AccountError.createErrorAlreadySubscribed: - self?.showAlreadySubscribedError(url.absoluteString) + self.showAlreadySubscribedError(url.absoluteString) case AccountError.createErrorNotFound: - self?.showNoFeedsErrorMessage() + self.showNoFeedsErrorMessage() default: NSApplication.shared.presentError(error) } @@ -125,45 +127,6 @@ private extension AddFeedController { } } - func processFeed(_ feed: Feed, account: Account, folder: Folder?, url: URL, title: String?) { - - if let title = title { - account.renameFeed(feed, to: title) { result in - switch result { - case .success: - break - case .failure(let error): - NSApplication.shared.presentError(error) - } - } - } - - if let folder = folder { - folder.addFeed(feed) { result in - switch result { - case .success: - BatchUpdate.shared.end() - NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed]) - case .failure(let error): - BatchUpdate.shared.end() - NSApplication.shared.presentError(error) - } - } - } else { - account.addFeed(feed) { result in - switch result { - case .success: - BatchUpdate.shared.end() - NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed]) - case .failure(let error): - BatchUpdate.shared.end() - NSApplication.shared.presentError(error) - } - } - } - - } - // MARK: Errors func showAlreadySubscribedError(_ urlString: String) { diff --git a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift index 8c5946f67..5c05b2af5 100644 --- a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift @@ -29,7 +29,7 @@ class AddFeedWindowController : NSWindowController { private var urlString: String? private var initialName: String? - private var initialAccount: Account? + private weak var initialAccount: Account? private var initialFolder: Folder? private weak var delegate: AddFeedWindowControllerDelegate? private var folderTreeController: TreeController! diff --git a/Mac/MainWindow/OPML/ExportOPMLSheet.xib b/Mac/MainWindow/OPML/ExportOPMLSheet.xib index ac2db2a7f..e6bc9f85b 100644 --- a/Mac/MainWindow/OPML/ExportOPMLSheet.xib +++ b/Mac/MainWindow/OPML/ExportOPMLSheet.xib @@ -13,53 +13,49 @@ - - + + - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + + + + + - + diff --git a/Mac/MainWindow/OPML/ExportOPMLWindowController.swift b/Mac/MainWindow/OPML/ExportOPMLWindowController.swift index 5b7250875..cdbda991c 100644 --- a/Mac/MainWindow/OPML/ExportOPMLWindowController.swift +++ b/Mac/MainWindow/OPML/ExportOPMLWindowController.swift @@ -84,7 +84,7 @@ class ExportOPMLWindowController: NSWindowController { panel.isExtensionHidden = false let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces) - panel.nameFieldStringValue = "\(accountName).opml" + panel.nameFieldStringValue = "Subscriptions-\(accountName).opml" panel.beginSheetModal(for: hostWindow!) { result in if result == NSApplication.ModalResponse.OK, let url = panel.url { diff --git a/Mac/MainWindow/OPML/ImportOPMLSheet.xib b/Mac/MainWindow/OPML/ImportOPMLSheet.xib index 14b0dded9..af5b645db 100644 --- a/Mac/MainWindow/OPML/ImportOPMLSheet.xib +++ b/Mac/MainWindow/OPML/ImportOPMLSheet.xib @@ -13,65 +13,47 @@ - - + + - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + - + - + + + - + diff --git a/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift b/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift deleted file mode 100644 index b87ade020..000000000 --- a/Mac/MainWindow/Sidebar/FolderPasteboardWriter.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// FolderPasteboardWriter.swift -// NetNewsWire -// -// Created by Brent Simmons on 2/11/18. -// Copyright © 2018 Ranchero Software. All rights reserved. -// - -import AppKit -import Account -import RSCore - -extension Folder: PasteboardWriterOwner { - - public var pasteboardWriter: NSPasteboardWriting { - return FolderPasteboardWriter(folder: self) - } -} - -@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting { - - private let folder: Folder - static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder" - static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal) - - init(folder: Folder) { - - self.folder = folder - } - - // MARK: - NSPasteboardWriting - - func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { - - return [.string, FolderPasteboardWriter.folderUTIInternalType] - } - - func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { - - let plist: Any? - - switch type { - case .string: - plist = folder.nameForDisplay - case FolderPasteboardWriter.folderUTIInternalType: - plist = internalDictionary() - default: - plist = nil - } - - return plist - } -} - -private extension FolderPasteboardWriter { - - private struct Key { - - static let name = "name" - - // Internal - static let accountID = "accountID" - static let folderID = "folderID" - } - - func internalDictionary() -> [String: Any] { - - var d = [String: Any]() - - d[Key.folderID] = folder.folderID - if let name = folder.name { - d[Key.name] = name - } - if let accountID = folder.account?.accountID { - d[Key.accountID] = accountID - } - - return d - - } -} - diff --git a/Mac/MainWindow/Sidebar/PasteboardFeed.swift b/Mac/MainWindow/Sidebar/PasteboardFeed.swift index 03cf72a6e..5e0bc89e3 100644 --- a/Mac/MainWindow/Sidebar/PasteboardFeed.swift +++ b/Mac/MainWindow/Sidebar/PasteboardFeed.swift @@ -22,6 +22,7 @@ struct PasteboardFeed: Hashable { // Internal static let accountID = "accountID" + static let accountType = "accountType" static let feedID = "feedID" static let editedName = "editedName" } @@ -32,15 +33,17 @@ struct PasteboardFeed: Hashable { let name: String? let editedName: String? let accountID: String? + let accountType: AccountType? let isLocalFeed: Bool - init(url: String, feedID: String?, homePageURL: String?, name: String?, editedName: String?, accountID: String?) { + init(url: String, feedID: String?, homePageURL: String?, name: String?, editedName: String?, accountID: String?, accountType: AccountType?) { self.url = url.rs_normalizedURL() self.feedID = feedID self.homePageURL = homePageURL?.rs_normalizedURL() self.name = name self.editedName = editedName self.accountID = accountID + self.accountType = accountType self.isLocalFeed = accountID != nil } @@ -57,7 +60,12 @@ struct PasteboardFeed: Hashable { let feedID = dictionary[Key.feedID] let editedName = dictionary[Key.editedName] - self.init(url: url, feedID: feedID, homePageURL: homePageURL, name: name, editedName: editedName, accountID: accountID) + var accountType: AccountType? = nil + if let accountTypeString = dictionary[Key.accountType], let accountTypeInt = Int(accountTypeString) { + accountType = AccountType(rawValue: accountTypeInt) + } + + self.init(url: url, feedID: feedID, homePageURL: homePageURL, name: name, editedName: editedName, accountID: accountID, accountType: accountType) } init?(pasteboardItem: NSPasteboardItem) { @@ -86,7 +94,7 @@ struct PasteboardFeed: Hashable { if let foundType = pasteboardType { if let possibleURLString = pasteboardItem.string(forType: foundType) { if possibleURLString.rs_stringMayBeURL() { - self.init(url: possibleURLString, feedID: nil, homePageURL: nil, name: nil, editedName: nil, accountID: nil) + self.init(url: possibleURLString, feedID: nil, homePageURL: nil, name: nil, editedName: nil, accountID: nil, accountType: nil) return } } @@ -131,6 +139,9 @@ struct PasteboardFeed: Hashable { if let accountID = accountID { d[PasteboardFeed.Key.accountID] = accountID } + if let accountType = accountType { + d[PasteboardFeed.Key.accountType] = String(accountType.rawValue) + } return d } } @@ -186,7 +197,7 @@ extension Feed: PasteboardWriterOwner { private extension FeedPasteboardWriter { var pasteboardFeed: PasteboardFeed { - return PasteboardFeed(url: feed.url, feedID: feed.feedID, homePageURL: feed.homePageURL, name: feed.name, editedName: feed.editedName, accountID: feed.account?.accountID) + return PasteboardFeed(url: feed.url, feedID: feed.feedID, homePageURL: feed.homePageURL, name: feed.name, editedName: feed.editedName, accountID: feed.account?.accountID, accountType: feed.account?.type) } var exportDictionary: PasteboardFeedDictionary { diff --git a/Mac/MainWindow/Sidebar/PasteboardFolder.swift b/Mac/MainWindow/Sidebar/PasteboardFolder.swift new file mode 100644 index 000000000..2a7e14eb8 --- /dev/null +++ b/Mac/MainWindow/Sidebar/PasteboardFolder.swift @@ -0,0 +1,137 @@ +// +// FolderPasteboardWriter.swift +// NetNewsWire +// +// Created by Brent Simmons on 2/11/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSCore + +typealias PasteboardFolderDictionary = [String: String] + +struct PasteboardFolder: Hashable { + + private struct Key { + static let name = "name" + // Internal + static let folderID = "folderID" + static let accountID = "accountID" + } + + + let name: String + let folderID: String? + let accountID: String? + + init(name: String, folderID: String?, accountID: String?) { + self.name = name + self.folderID = folderID + self.accountID = accountID + } + + // MARK: - Reading + + init?(dictionary: PasteboardFolderDictionary) { + guard let name = dictionary[Key.name] else { + return nil + } + + let folderID = dictionary[Key.folderID] + let accountID = dictionary[Key.accountID] + + self.init(name: name, folderID: folderID, accountID: accountID) + } + + init?(pasteboardItem: NSPasteboardItem) { + var pasteboardType: NSPasteboard.PasteboardType? + if pasteboardItem.types.contains(FolderPasteboardWriter.folderUTIInternalType) { + pasteboardType = FolderPasteboardWriter.folderUTIInternalType + } + + if let foundType = pasteboardType { + if let folderDictionary = pasteboardItem.propertyList(forType: foundType) as? PasteboardFeedDictionary { + self.init(dictionary: folderDictionary) + return + } + } + + return nil + } + + static func pasteboardFolders(with pasteboard: NSPasteboard) -> Set? { + guard let items = pasteboard.pasteboardItems else { + return nil + } + let folders = items.compactMap { PasteboardFolder(pasteboardItem: $0) } + return folders.isEmpty ? nil : Set(folders) + } + + // MARK: - Writing + + func internalDictionary() -> PasteboardFolderDictionary { + var d = PasteboardFeedDictionary() + d[PasteboardFolder.Key.name] = name + if let folderID = folderID { + d[PasteboardFolder.Key.folderID] = folderID + } + if let accountID = accountID { + d[PasteboardFolder.Key.accountID] = accountID + } + return d + } +} + +extension Folder: PasteboardWriterOwner { + + public var pasteboardWriter: NSPasteboardWriting { + return FolderPasteboardWriter(folder: self) + } +} + +@objc final class FolderPasteboardWriter: NSObject, NSPasteboardWriting { + + private let folder: Folder + static let folderUTIInternal = "com.ranchero.NetNewsWire-Evergreen.internal.folder" + static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal) + + init(folder: Folder) { + + self.folder = folder + } + + // MARK: - NSPasteboardWriting + + func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { + + return [.string, FolderPasteboardWriter.folderUTIInternalType] + } + + func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { + + let plist: Any? + + switch type { + case .string: + plist = folder.nameForDisplay + case FolderPasteboardWriter.folderUTIInternalType: + plist = internalDictionary + default: + plist = nil + } + + return plist + } +} + +private extension FolderPasteboardWriter { + var pasteboardFolder: PasteboardFolder { + return PasteboardFolder(name: folder.name ?? "", folderID: String(folder.folderID), accountID: folder.account?.accountID) + } + + var internalDictionary: PasteboardFeedDictionary { + return pasteboardFolder.internalDictionary() + } +} diff --git a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift index 08f4f833b..935b97458 100644 --- a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift +++ b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift @@ -54,46 +54,70 @@ import Account } func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { - guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else { + let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard) + let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard) + if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) { return SidebarOutlineDataSource.dragOperationNone } - let parentNode = nodeForItem(item) - let contentsType = draggedFeedContentsType(draggedFeeds) - switch contentsType { - case .singleNonLocal: - let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! - return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) - case .singleLocal: - let draggedFeed = draggedFeeds.first! - return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index) - case .multipleLocal: - return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleNonLocal, .mixed, .empty: - return SidebarOutlineDataSource.dragOperationNone + if let draggedFolders = draggedFolders { + if draggedFolders.count == 1 { + return validateLocalFolderDrop(outlineView, draggedFolders.first!, parentNode, index) + } else { + return validateLocalFoldersDrop(outlineView, draggedFolders, parentNode, index) + } } + + if let draggedFeeds = draggedFeeds { + let contentsType = draggedFeedContentsType(draggedFeeds) + + switch contentsType { + case .singleNonLocal: + let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! + return validateSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) + case .singleLocal: + let draggedFeed = draggedFeeds.first! + return validateSingleLocalFeedDrop(outlineView, draggedFeed, parentNode, index) + case .multipleLocal: + return validateLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleNonLocal, .mixed, .empty: + return SidebarOutlineDataSource.dragOperationNone + } + } + + return SidebarOutlineDataSource.dragOperationNone } func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { - guard let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard), !draggedFeeds.isEmpty else { + let draggedFolders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard) + let draggedFeeds = PasteboardFeed.pasteboardFeeds(with: info.draggingPasteboard) + if (draggedFolders == nil && draggedFeeds == nil) || (draggedFolders != nil && draggedFeeds != nil) { return false } - let parentNode = nodeForItem(item) - let contentsType = draggedFeedContentsType(draggedFeeds) - switch contentsType { - case .singleNonLocal: - let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! - return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) - case .singleLocal: - return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleLocal: - return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) - case .multipleNonLocal, .mixed, .empty: - return false + if let draggedFolders = draggedFolders { + return acceptLocalFoldersDrop(outlineView, draggedFolders, parentNode, index) } + + if let draggedFeeds = draggedFeeds { + let contentsType = draggedFeedContentsType(draggedFeeds) + + switch contentsType { + case .singleNonLocal: + let draggedNonLocalFeed = singleNonLocalFeed(from: draggedFeeds)! + return acceptSingleNonLocalFeedDrop(outlineView, draggedNonLocalFeed, parentNode, index) + case .singleLocal: + return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleLocal: + return acceptLocalFeedsDrop(outlineView, draggedFeeds, parentNode, index) + case .multipleNonLocal, .mixed, .empty: + return false + } + } + + return false } } @@ -109,11 +133,10 @@ private extension SidebarOutlineDataSource { } func nodeRepresentsDraggableItem(_ node: Node) -> Bool { - // Don’t allow PseudoFeed or Folder to be dragged. + // Don’t allow PseudoFeed to be dragged. // This will have to be revisited later. For instance, // user-created smart feeds should be draggable, maybe. - // And we might allow dragging folders between accounts. - return node.representedObject is Feed + return node.representedObject is Folder || node.representedObject is Feed } // MARK: - Drag and Drop @@ -173,20 +196,20 @@ private extension SidebarOutlineDataSource { guard let dropTargetNode = ancestorThatCanAcceptLocalFeed(parentNode) else { return SidebarOutlineDataSource.dragOperationNone } - if nodeAndDraggedFeedsDoNotShareAccount(dropTargetNode, Set([draggedFeed])) { - return SidebarOutlineDataSource.dragOperationNone - } if nodeHasChildRepresentingDraggedFeed(dropTargetNode, draggedFeed) { return SidebarOutlineDataSource.dragOperationNone } + if violatesTagSpecificBehavior(dropTargetNode, draggedFeed) { + return SidebarOutlineDataSource.dragOperationNone + } if parentNode == dropTargetNode && index == NSOutlineViewDropOnItemIndex { - return .move + return localDragOperation() } let updatedIndex = indexWhereDraggedFeedWouldAppear(dropTargetNode, draggedFeed) if parentNode !== dropTargetNode || index != updatedIndex { outlineView.setDropItem(dropTargetNode, dropChildIndex: updatedIndex) } - return .move + return localDragOperation() } func validateLocalFeedsDrop(_ outlineView: NSOutlineView, _ draggedFeeds: Set, _ parentNode: Node, _ index: Int) -> NSDragOperation { @@ -194,19 +217,27 @@ private extension SidebarOutlineDataSource { guard let dropTargetNode = ancestorThatCanAcceptLocalFeed(parentNode) else { return SidebarOutlineDataSource.dragOperationNone } - if nodeAndDraggedFeedsDoNotShareAccount(dropTargetNode, draggedFeeds) { + if nodeHasChildRepresentingAnyDraggedFeed(dropTargetNode, draggedFeeds) { return SidebarOutlineDataSource.dragOperationNone } - if nodeHasChildRepresentingAnyDraggedFeed(dropTargetNode, draggedFeeds) { + if violatesTagSpecificBehavior(dropTargetNode, draggedFeeds) { return SidebarOutlineDataSource.dragOperationNone } if parentNode !== dropTargetNode || index != NSOutlineViewDropOnItemIndex { outlineView.setDropItem(dropTargetNode, dropChildIndex: NSOutlineViewDropOnItemIndex) } - return .move + return localDragOperation() + } + + func localDragOperation() -> NSDragOperation { + if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false { + return .copy + } else { + return .move + } } - private func accountForNode(_ node: Node) -> Account? { + func accountForNode(_ node: Node) -> Account? { if let account = node.representedObject as? Account { return account } @@ -219,65 +250,193 @@ private extension SidebarOutlineDataSource { return nil } - private func commonAccountFor(_ nodes: Set) -> Account? { - // Return the Account if every node has an Account and they’re all the same. - var account: Account? = nil + func commonAccountsFor(_ nodes: Set) -> Set { + + var accounts = Set() for node in nodes { guard let oneAccount = accountForNode(node) else { - return nil - } - if account == nil { - account = oneAccount - } - else { - if account != oneAccount { - return nil - } + continue } + accounts.insert(oneAccount) } - return account + return accounts } - private func move(node: Node, to parentNode: Node, account: Account) { - guard let feed = node.representedObject as? Feed else { + func accountHasFolderRepresentingAnyDraggedFolders(_ account: Account, _ draggedFolders: Set) -> Bool { + for draggedFolder in draggedFolders { + if account.existingFolder(with: draggedFolder.name) != nil { + return true + } + } + return false + } + + func validateLocalFolderDrop(_ outlineView: NSOutlineView, _ draggedFolder: PasteboardFolder, _ parentNode: Node, _ index: Int) -> NSDragOperation { + guard let dropAccount = parentNode.representedObject as? Account, dropAccount.accountID != draggedFolder.accountID else { + return SidebarOutlineDataSource.dragOperationNone + } + if accountHasFolderRepresentingAnyDraggedFolders(dropAccount, Set([draggedFolder])) { + return SidebarOutlineDataSource.dragOperationNone + } + let updatedIndex = indexWhereDraggedFolderWouldAppear(parentNode, draggedFolder) + if index != updatedIndex { + outlineView.setDropItem(parentNode, dropChildIndex: updatedIndex) + } + return localDragOperation() + } + + func validateLocalFoldersDrop(_ outlineView: NSOutlineView, _ draggedFolders: Set, _ parentNode: Node, _ index: Int) -> NSDragOperation { + guard let dropAccount = parentNode.representedObject as? Account else { + return SidebarOutlineDataSource.dragOperationNone + } + if accountHasFolderRepresentingAnyDraggedFolders(dropAccount, draggedFolders) { + return SidebarOutlineDataSource.dragOperationNone + } + for draggedFolder in draggedFolders { + if dropAccount.accountID == draggedFolder.accountID { + return SidebarOutlineDataSource.dragOperationNone + } + } + if index != NSOutlineViewDropOnItemIndex { + outlineView.setDropItem(parentNode, dropChildIndex: NSOutlineViewDropOnItemIndex) + } + return localDragOperation() + } + + func copyFeedInAccount(node: Node, to parentNode: Node) { + guard let feed = node.representedObject as? Feed, let destination = parentNode.representedObject as? Container else { return } - let source = node.parent?.representedObject as? Container - let destination = parentNode.representedObject as? Container - BatchUpdate.shared.start() - source?.removeFeed(feed) { result in + + destination.account?.addFeed(feed, to: destination) { result in switch result { case .success: - destination?.addFeed(feed) { result in - switch result { - case .success: - BatchUpdate.shared.end() - break - case .failure(let error): - // If the second part of the move failed, try to put the feed back - source?.addFeed(feed) { result in} - BatchUpdate.shared.end() - NSApplication.shared.presentError(error) - } - } + break case .failure(let error): NSApplication.shared.presentError(error) } } } + func moveFeedInAccount(node: Node, to parentNode: Node) { + guard let feed = node.representedObject as? Feed, + let source = node.parent?.representedObject as? Container, + let destination = parentNode.representedObject as? Container else { + return + } + + BatchUpdate.shared.start() + source.account?.moveFeed(feed, from: source, to: destination) { result in + switch result { + case .success: + BatchUpdate.shared.end() + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + + func copyFeedBetweenAccounts(node: Node, to parentNode: Node) { + guard let feed = node.representedObject as? Feed, + let destinationAccount = nodeAccount(parentNode), + let destinationContainer = parentNode.representedObject as? Container else { + return + } + + if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) { + destinationAccount.addFeed(existingFeed, to: destinationContainer) { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } else { + destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + } + + func moveFeedBetweenAccounts(node: Node, to parentNode: Node) { + guard let feed = node.representedObject as? Feed, + let sourceAccount = nodeAccount(node), + let sourceContainer = node.parent?.representedObject as? Container, + let destinationAccount = nodeAccount(parentNode), + let destinationContainer = parentNode.representedObject as? Container else { + return + } + + if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) { + + BatchUpdate.shared.start() + destinationAccount.addFeed(existingFeed, to: destinationContainer) { result in + switch result { + case .success: + sourceAccount.removeFeed(feed, from: sourceContainer) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + + } else { + + BatchUpdate.shared.start() + destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in + switch result { + case .success: + sourceAccount.removeFeed(feed, from: sourceContainer) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + + } + } + func acceptLocalFeedsDrop(_ outlineView: NSOutlineView, _ draggedFeeds: Set, _ parentNode: Node, _ index: Int) -> Bool { guard let draggedNodes = draggedNodes else { return false } - let allReferencedNodes = draggedNodes.union(Set([parentNode])) - guard let account = commonAccountFor(allReferencedNodes) else { - return false + + draggedNodes.forEach { node in + if sameAccount(node, parentNode) { + if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false { + copyFeedInAccount(node: node, to: parentNode) + } else { + moveFeedInAccount(node: node, to: parentNode) + } + } else { + if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false { + copyFeedBetweenAccounts(node: node, to: parentNode) + } else { + moveFeedBetweenAccounts(node: node, to: parentNode) + } + } } - BatchUpdate.shared.perform { - draggedNodes.forEach { move(node: $0, to: parentNode, account: account) } - } - account.structureDidChange() + return true } @@ -313,6 +472,94 @@ private extension SidebarOutlineDataSource { return ancestorThatCanAcceptNonLocalFeed(parentNode) } + func copyFolderBetweenAccounts(node: Node, to parentNode: Node) { + guard let sourceFolder = node.representedObject as? Folder, + let destinationAccount = nodeAccount(parentNode) else { + return + } + replicateFolder(sourceFolder, destinationAccount: destinationAccount, completion: {}) + } + + func moveFolderBetweenAccounts(node: Node, to parentNode: Node) { + guard let sourceFolder = node.representedObject as? Folder, + let sourceAccount = nodeAccount(node), + let destinationAccount = nodeAccount(parentNode) else { + return + } + + BatchUpdate.shared.start() + replicateFolder(sourceFolder, destinationAccount: destinationAccount) { + sourceAccount.removeFolder(sourceFolder) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + } + + func replicateFolder(_ folder: Folder, destinationAccount: Account, completion: @escaping () -> Void) { + destinationAccount.addFolder(folder.name ?? "") { result in + switch result { + case .success(let destinationFolder): + let group = DispatchGroup() + for feed in folder.topLevelFeeds { + if let existingFeed = destinationAccount.existingFeed(withURL: feed.url) { + group.enter() + destinationAccount.addFeed(existingFeed, to: destinationFolder) { result in + group.leave() + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } else { + group.enter() + destinationAccount.createFeed(url: feed.url, name: feed.editedName, container: destinationFolder) { result in + group.leave() + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + } + group.notify(queue: DispatchQueue.main) { + completion() + } + case .failure(let error): + NSApplication.shared.presentError(error) + completion() + } + } + + } + + func acceptLocalFoldersDrop(_ outlineView: NSOutlineView, _ draggedFolders: Set, _ parentNode: Node, _ index: Int) -> Bool { + guard let draggedNodes = draggedNodes else { + return false + } + + draggedNodes.forEach { node in + if !sameAccount(node, parentNode) { + if NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false { + copyFolderBetweenAccounts(node: node, to: parentNode) + } else { + moveFolderBetweenAccounts(node: node, to: parentNode) + } + } + } + + return true + } + func acceptSingleNonLocalFeedDrop(_ outlineView: NSOutlineView, _ draggedFeed: PasteboardFeed, _ parentNode: Node, _ index: Int) -> Bool { guard nodeIsDropTarget(parentNode), index == NSOutlineViewDropOnItemIndex else { return false @@ -346,27 +593,32 @@ private extension SidebarOutlineDataSource { return false } - func nodeAndDraggedFeedsDoNotShareAccount(_ parentNode: Node, _ draggedFeeds: Set) -> Bool { - - let parentAccountId: String? - if let account = parentNode.representedObject as? Account { - parentAccountId = account.accountID - } else if let folder = parentNode.representedObject as? Folder { - parentAccountId = folder.account?.accountID - } else { - return true - } - - for draggedFeed in draggedFeeds { - if draggedFeed.accountID != parentAccountId { + func sameAccount(_ node: Node, _ parentNode: Node) -> Bool { + if let accountID = nodeAccountID(node), let parentAccountID = nodeAccountID(parentNode) { + if accountID == parentAccountID { return true } } - return false - } + + func nodeAccount(_ node: Node) -> Account? { + if let account = node.representedObject as? Account { + return account + } else if let folder = node.representedObject as? Folder { + return folder.account + } else if let feed = node.representedObject as? Feed { + return feed.account + } else { + return nil + } + } + + func nodeAccountID(_ node: Node) -> String? { + return nodeAccount(node)?.accountID + } + func nodeHasChildRepresentingAnyDraggedFeed(_ parentNode: Node, _ draggedFeeds: Set) -> Bool { for node in parentNode.childNodes { if nodeRepresentsAnyDraggedFeed(node, draggedFeeds) { @@ -376,6 +628,29 @@ private extension SidebarOutlineDataSource { return false } + func violatesTagSpecificBehavior(_ parentNode: Node, _ draggedFeed: PasteboardFeed) -> Bool { + return violatesTagSpecificBehavior(parentNode, Set([draggedFeed])) + } + + func violatesTagSpecificBehavior(_ parentNode: Node, _ draggedFeeds: Set) -> Bool { + guard let parentAccount = nodeAccount(parentNode), parentAccount.usesTags else { + return false + } + + for draggedFeed in draggedFeeds { + if parentAccount.accountID != draggedFeed.accountID { + return false + } + } + + // Can't copy to the account when using tags + if parentNode.representedObject is Account && (NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false) { + return true + } + + return false + } + func indexWhereDraggedFeedWouldAppear(_ parentNode: Node, _ draggedFeed: PasteboardFeed) -> Int { let draggedFeedWrapper = PasteboardFeedObjectWrapper(pasteboardFeed: draggedFeed) let draggedFeedNode = Node(representedObject: draggedFeedWrapper, parent: nil) @@ -386,6 +661,18 @@ private extension SidebarOutlineDataSource { let index = sortedNodes.firstIndex(of: draggedFeedNode)! return index } + + func indexWhereDraggedFolderWouldAppear(_ parentNode: Node, _ draggedFolder: PasteboardFolder) -> Int { + let draggedFolderWrapper = PasteboardFolderObjectWrapper(pasteboardFolder: draggedFolder) + let draggedFolderNode = Node(representedObject: draggedFolderWrapper, parent: nil) + draggedFolderNode.canHaveChildNodes = true + let nodes = parentNode.childNodes + [draggedFolderNode] + + // Revisit if the tree controller can ever be sorted in some other way. + let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd() + let index = sortedNodes.firstIndex(of: draggedFolderNode)! + return index + } } final class PasteboardFeedObjectWrapper: DisplayNameProvider { @@ -399,3 +686,15 @@ final class PasteboardFeedObjectWrapper: DisplayNameProvider { self.pasteboardFeed = pasteboardFeed } } + +final class PasteboardFolderObjectWrapper: DisplayNameProvider { + + var nameForDisplay: String { + return pasteboardFolder.name + } + let pasteboardFolder: PasteboardFolder + + init(pasteboardFolder: PasteboardFolder) { + self.pasteboardFolder = pasteboardFolder + } +} diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 7b5e64f85..c206be8aa 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -47,8 +47,7 @@ protocol SidebarDelegate: class { sidebarCellAppearance = SidebarCellAppearance(fontSize: AppDefaults.sidebarFontSize) outlineView.dataSource = dataSource - outlineView.setDraggingSourceOperationMask(.move, forLocal: true) - outlineView.setDraggingSourceOperationMask(.copy, forLocal: false) + outlineView.setDraggingSourceOperationMask([.move, .copy], forLocal: true) outlineView.registerForDraggedTypes([FeedPasteboardWriter.feedUTIInternalType, FeedPasteboardWriter.feedUTIType, .URL, .string]) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) @@ -61,6 +60,7 @@ protocol SidebarDelegate: class { 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(userDidRequestSidebarSelection(_:)), name: .UserDidRequestSidebarSelection, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(calendarDayChanged(_:)), name: .NSCalendarDayChanged, object: nil) outlineView.reloadData() @@ -166,6 +166,12 @@ protocol SidebarDelegate: class { revealAndSelectRepresentedObject(feed as AnyObject) } + @objc func calendarDayChanged(_ note: Notification) { + DispatchQueue.main.async { + SmartFeedsController.shared.todayFeed.fetchUnreadCounts() + } + } + // MARK: - Actions @IBAction func delete(_ sender: AnyObject?) { diff --git a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift index 607d00bb7..ed18581e9 100644 --- a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift +++ b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift @@ -18,15 +18,18 @@ final class SingleLineTextFieldSizer { private let textField: NSTextField private var cache = [String: NSSize]() - init(font: NSFont) { + /// Get the NSTextField size for text, given a font. + static func size(for text: String, font: NSFont) -> NSSize { + return sizer(for: font).size(for: text) + } + init(font: NSFont) { self.textField = NSTextField(labelWithString: "") self.textField.font = font self.font = font } func size(for text: String) -> NSSize { - if let cachedSize = cache[text] { return cachedSize } @@ -40,29 +43,23 @@ final class SingleLineTextFieldSizer { return calculatedSize } - static private var sizers = [NSFont: SingleLineTextFieldSizer]() + static private var sizers = [SingleLineTextFieldSizer]() - static func sizer(for font: NSFont) -> SingleLineTextFieldSizer { - - if let cachedSizer = sizers[font] { + static private func sizer(for font: NSFont) -> SingleLineTextFieldSizer { + // We used to use an [NSFont: SingleLineTextFieldSizer] dictionary — + // until, in 10.14.5, we started getting crashes with the message: + // Fatal error: Duplicate keys of type 'NSFont' were found in a Dictionary. + // This usually means either that the type violates Hashable's requirements, or + // that members of such a dictionary were mutated after insertion. + // We use just an array of sizers now — which is totally fine, + // because there’s only going to be like three of them. + if let cachedSizer = sizers.firstElementPassingTest({ $0.font == font }) { return cachedSizer } let newSizer = SingleLineTextFieldSizer(font: font) - sizers[font] = newSizer + sizers.append(newSizer) return newSizer } - - // Use this call. It’s easiest. - - static func size(for text: String, font: NSFont) -> NSSize { - - return sizer(for: font).size(for: text) - } - - static func emptyCache() { - - sizers = [NSFont: SingleLineTextFieldSizer]() - } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 1aa349a86..3a2ca3ea9 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -147,6 +147,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner { NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .AccountsDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(calendarDayChanged(_:)), name: .NSCalendarDayChanged, object: nil) didRegisterForNotifications = true } @@ -511,6 +512,14 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner { self.fontSize = AppDefaults.timelineFontSize self.sortDirection = AppDefaults.timelineSortDirection } + + @objc func calendarDayChanged(_ note: Notification) { + if representedObjectsContainsTodayFeed() { + DispatchQueue.main.async { [weak self] in + self?.fetchArticles() + } + } + } // MARK: - Reloading Data @@ -966,6 +975,10 @@ private extension TimelineViewController { return representedObjects?.contains(where: { $0 is PseudoFeed}) ?? false } + func representedObjectsContainsTodayFeed() -> Bool { + return representedObjects?.contains(where: { $0 === SmartFeedsController.shared.todayFeed }) ?? false + } + func representedObjectsContainsAnyFeed(_ feeds: Set) -> Bool { // Return true if there’s a match or if a folder contains (recursively) one of feeds diff --git a/Mac/Preferences/Accounts/AccountsDetail.xib b/Mac/Preferences/Accounts/AccountsDetail.xib index 12ecf43f9..82b5891c9 100644 --- a/Mac/Preferences/Accounts/AccountsDetail.xib +++ b/Mac/Preferences/Accounts/AccountsDetail.xib @@ -26,12 +26,13 @@ - - + + + @@ -40,7 +41,7 @@ - + @@ -50,7 +51,7 @@ - + @@ -61,7 +62,7 @@