diff --git a/Account/Package.swift b/Account/Package.swift index 695d15377..bc3a13dc8 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -24,6 +24,7 @@ let package = Package( .package(path: "../CloudKitSync"), .package(path: "../NewsBlur"), .package(path: "../Feedbin"), + .package(path: "../FeedFinder"), .package(path: "../CommonErrors") ], targets: [ @@ -43,6 +44,7 @@ let package = Package( "NewsBlur", "CloudKitSync", "Feedbin", + "FeedFinder", "CommonErrors" ], swiftSettings: [ diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 80257ebae..8b8c01581 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -19,6 +19,7 @@ import Web import os.log import Secrets import Core +import CommonErrors // Main thread only. diff --git a/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift index 7619ef3fc..7b31b61c1 100644 --- a/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift @@ -14,6 +14,8 @@ import ArticlesDatabase import Web import Secrets import Core +import CommonErrors +import FeedFinder public enum LocalAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." diff --git a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift index 211b69659..7079f1633 100644 --- a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift @@ -15,6 +15,7 @@ import SyncDatabase import os.log import Core import NewsBlur +import CommonErrors extension NewsBlurAccountDelegate { diff --git a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift index 37cb5385d..662571d91 100644 --- a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift @@ -14,6 +14,7 @@ import SyncDatabase import os.log import Secrets import NewsBlur +import CommonErrors final class NewsBlurAccountDelegate: AccountDelegate { diff --git a/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift index e4f5c9bad..ba8e85506 100644 --- a/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift @@ -15,6 +15,8 @@ import Secrets import Database import Core import ReaderAPI +import CommonErrors +import FeedFinder final class ReaderAPIAccountDelegate: AccountDelegate { diff --git a/Account/Sources/Account/AccountError.swift b/Account/Sources/Account/AccountError.swift index 8afc56c94..23f7817d4 100644 --- a/Account/Sources/Account/AccountError.swift +++ b/Account/Sources/Account/AccountError.swift @@ -10,9 +10,7 @@ import Foundation import Web import CommonErrors -typealias AccountError = CommonError // Temporary, for compatibility with existing code - -public extension CommonError { +public extension AccountError { @MainActor var account: Account? { if case .wrappedError(_, let accountID, _) = self { @@ -22,7 +20,7 @@ public extension CommonError { } } - @MainActor static func wrappedError(error: Error, account: Account) -> CommonError { + @MainActor static func wrappedError(error: Error, account: Account) -> AccountError { wrappedError(error: error, accountID: account.accountID, accountName: account.nameForDisplay) } } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index b0c0531d5..51c5c568c 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -18,6 +18,8 @@ import Web import Secrets import Core import CloudKitExtras +import CommonErrors +import FeedFinder enum CloudKitAccountDelegateError: LocalizedError { case invalidParameter @@ -820,80 +822,82 @@ private extension CloudKitAccountDelegate { refreshProgress.addToNumberOfTasksAndRemaining(5) FeedFinder.find(url: url) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let feedSpecifiers): - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { + MainActor.assumeIsolated { + self.refreshProgress.completeTask() + switch result { + case .success(let feedSpecifiers): + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { + self.refreshProgress.completeTasks(3) + if validateFeed { + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorNotFound)) + } else { + addDeadFeed() + } + return + } + + if account.hasFeed(withURL: bestFeedSpecifier.urlString) { + self.refreshProgress.completeTasks(4) + completion(.failure(AccountError.createErrorAlreadySubscribed)) + return + } + + let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) + feed.editedName = editedName + container.addFeed(feed) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() + + if let parsedFeed { + + Task { @MainActor in + + do { + try await account.update(feed: feed, with: parsedFeed) + + self.accountZone.createFeed(url: bestFeedSpecifier.urlString, + name: parsedFeed.title, + editedName: editedName, + homePageURL: parsedFeed.homePageURL, + container: container) { result in + + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + feed.externalID = externalID + self.sendNewArticlesToTheCloud(account, feed) + completion(.success(feed)) + case .failure(let error): + container.removeFeed(feed) + self.refreshProgress.completeTasks(2) + completion(.failure(error)) + } + + } + } catch { + container.removeFeed(feed) + self.refreshProgress.completeTasks(3) + completion(.failure(error)) + } + } + } else { + self.refreshProgress.completeTasks(3) + container.removeFeed(feed) + completion(.failure(AccountError.createErrorNotFound)) + } + } + + case .failure: self.refreshProgress.completeTasks(3) if validateFeed { self.refreshProgress.completeTask() completion(.failure(AccountError.createErrorNotFound)) + return } else { addDeadFeed() } - return - } - - if account.hasFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTasks(4) - completion(.failure(AccountError.createErrorAlreadySubscribed)) - return - } - - let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) - feed.editedName = editedName - container.addFeed(feed) - - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() - - if let parsedFeed { - - Task { @MainActor in - - do { - try await account.update(feed: feed, with: parsedFeed) - - self.accountZone.createFeed(url: bestFeedSpecifier.urlString, - name: parsedFeed.title, - editedName: editedName, - homePageURL: parsedFeed.homePageURL, - container: container) { result in - - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - feed.externalID = externalID - self.sendNewArticlesToTheCloud(account, feed) - completion(.success(feed)) - case .failure(let error): - container.removeFeed(feed) - self.refreshProgress.completeTasks(2) - completion(.failure(error)) - } - - } - } catch { - container.removeFeed(feed) - self.refreshProgress.completeTasks(3) - completion(.failure(error)) - } - } - } else { - self.refreshProgress.completeTasks(3) - container.removeFeed(feed) - completion(.failure(AccountError.createErrorNotFound)) - } - } - - case .failure: - self.refreshProgress.completeTasks(3) - if validateFeed { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - return - } else { - addDeadFeed() } } } diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index e47838e2f..83ed3004e 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -15,6 +15,8 @@ import os.log import Secrets import Core import Feedbin +import CommonErrors +import FeedFinder public enum FeedbinAccountDelegateError: String, Error { case invalidParameter = "There was an invalid parameter passed." diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index 75434b32c..79c747790 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -13,6 +13,7 @@ import SyncDatabase import os.log import Secrets import Core +import CommonErrors final class FeedlyAccountDelegate: AccountDelegate { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift index e946391c7..a1bde8440 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift @@ -7,6 +7,7 @@ // import Foundation +import CommonErrors protocol FeedlyAddFeedToCollectionService { func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ()) diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index 0a2f45a23..9083fec03 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -12,6 +12,7 @@ import SyncDatabase import Web import Secrets import Core +import CommonErrors class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/CommonErrors/Sources/CommonErrors/CommonError.swift b/CommonErrors/Sources/CommonErrors/CommonError.swift index 33f6fbe7f..77e310f21 100644 --- a/CommonErrors/Sources/CommonErrors/CommonError.swift +++ b/CommonErrors/Sources/CommonErrors/CommonError.swift @@ -8,7 +8,7 @@ import Foundation import Web -public enum CommonError: LocalizedError { +public enum AccountError: LocalizedError { case createErrorNotFound case createErrorAlreadySubscribed @@ -73,7 +73,7 @@ public enum CommonError: LocalizedError { // MARK: Private -private extension CommonError { +private extension AccountError { func unknownError(_ error: Error, _ accountName: String) -> String { let localizedText = NSLocalizedString("An error occurred while processing the “%@” account: %@", comment: "Unknown error") diff --git a/FeedFinder/.gitignore b/FeedFinder/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FeedFinder/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/FeedFinder/Package.swift b/FeedFinder/Package.swift new file mode 100644 index 000000000..abf09412d --- /dev/null +++ b/FeedFinder/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "FeedFinder", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "FeedFinder", + targets: ["FeedFinder"]), + ], + dependencies: [ + .package(path: "../Web"), + .package(path: "../Parser"), + .package(path: "../FoundationExtras"), + .package(path: "../CommonErrors"), + ], + + targets: [ + .target( + name: "FeedFinder", + dependencies: [ + "Parser", + "Web", + "FoundationExtras", + "CommonErrors" + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "FeedFinderTests", + dependencies: ["FeedFinder"]), + ] +) diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/FeedFinder/Sources/FeedFinder/FeedFinder.swift similarity index 95% rename from Account/Sources/Account/FeedFinder/FeedFinder.swift rename to FeedFinder/Sources/FeedFinder/FeedFinder.swift index f2235f107..74e33d7c4 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/FeedFinder/Sources/FeedFinder/FeedFinder.swift @@ -9,10 +9,11 @@ import Foundation import Parser import Web +import CommonErrors -class FeedFinder { +@MainActor public final class FeedFinder { - static func find(url: URL) async throws -> Set { + @MainActor public static func find(url: URL) async throws -> Set { try await withCheckedThrowingContinuation { continuation in Task { @MainActor in @@ -28,7 +29,7 @@ class FeedFinder { } } - @MainActor static func find(url: URL, completion: @escaping (Result, Error>) -> Void) { + @MainActor public static func find(url: URL, completion: @escaping @Sendable (Result, Error>) -> Void) { downloadAddingToCache(url) { (data, response, error) in MainActor.assumeIsolated { diff --git a/Account/Sources/Account/FeedFinder/FeedSpecifier.swift b/FeedFinder/Sources/FeedFinder/FeedSpecifier.swift similarity index 89% rename from Account/Sources/Account/FeedFinder/FeedSpecifier.swift rename to FeedFinder/Sources/FeedFinder/FeedSpecifier.swift index 7429fb04d..2745b9724 100644 --- a/Account/Sources/Account/FeedFinder/FeedSpecifier.swift +++ b/FeedFinder/Sources/FeedFinder/FeedSpecifier.swift @@ -8,9 +8,9 @@ import Foundation -struct FeedSpecifier: Hashable, Sendable { +public struct FeedSpecifier: Hashable, Sendable { - enum Source: Int { + public enum Source: Int, Sendable { case UserEntered = 0, HTMLHead, HTMLLink func equalToOrBetterThan(_ otherSource: Source) -> Bool { @@ -26,6 +26,13 @@ struct FeedSpecifier: Hashable, Sendable { return calculatedScore() } + public init(title: String?, urlString: String, source: Source, orderFound: Int) { + self.title = title + self.urlString = urlString + self.source = source + self.orderFound = orderFound + } + func feedSpecifierByMerging(_ feedSpecifier: FeedSpecifier) -> FeedSpecifier { // Take the best data (non-nil title, better source) to create a new feed specifier; diff --git a/Account/Sources/Account/FeedFinder/HTMLFeedFinder.swift b/FeedFinder/Sources/FeedFinder/HTMLFeedFinder.swift similarity index 99% rename from Account/Sources/Account/FeedFinder/HTMLFeedFinder.swift rename to FeedFinder/Sources/FeedFinder/HTMLFeedFinder.swift index fb29cbdda..805bc047b 100644 --- a/Account/Sources/Account/FeedFinder/HTMLFeedFinder.swift +++ b/FeedFinder/Sources/FeedFinder/HTMLFeedFinder.swift @@ -7,6 +7,7 @@ // import Foundation +import FoundationExtras import Parser private let feedURLWordsToMatch = ["feed", "xml", "rss", "atom", "json"] diff --git a/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift b/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift new file mode 100644 index 000000000..62035f9e3 --- /dev/null +++ b/FeedFinder/Tests/FeedFinderTests/FeedFinderTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FeedFinder + +final class FeedFinderTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 603e97eeb..99ebf20ce 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -70,10 +70,10 @@ import CommonErrors let feed = try await account.createFeed(url: url.absoluteString, name: title, container: container, validateFeed: true) NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed]) - } catch CommonError.createErrorAlreadySubscribed { + } catch AccountError.createErrorAlreadySubscribed { self.showAlreadySubscribedError(url.absoluteString) - } catch CommonError.createErrorNotFound { + } catch AccountError.createErrorNotFound { self.showNoFeedsErrorMessage() } catch { diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 4b125885e..a82b26e3f 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1467,6 +1467,7 @@ 84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84FB9FAC2BC33AFE00B7AFC3 /* NewsBlur */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = NewsBlur; sourceTree = ""; }; 84FB9FAD2BC344F800B7AFC3 /* Feedbin */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Feedbin; sourceTree = ""; }; + 84FB9FAE2BC3494B00B7AFC3 /* FeedFinder */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FeedFinder; sourceTree = ""; }; 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = ""; }; B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; @@ -2359,6 +2360,7 @@ 849C64611ED37A5D003D8FC0 /* Products */, 51C452B22265141B00C03939 /* Frameworks */, 51CD32C624D2DEF9009ABAEF /* Account */, + 84FB9FAE2BC3494B00B7AFC3 /* FeedFinder */, 84FB9FAD2BC344F800B7AFC3 /* Feedbin */, 84FB9FAC2BC33AFE00B7AFC3 /* NewsBlur */, 84CC98D92BC1DD25006A05C9 /* ReaderAPI */, diff --git a/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift b/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift index 3742c747a..8cfef5619 100644 --- a/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift +++ b/ReaderAPI/Sources/ReaderAPI/ReaderAPICaller.swift @@ -297,10 +297,10 @@ public enum CreateReaderAPISubscriptionResult: Sendable { // There is no call to get a single subscription entry, so we get them all, // look up the one we just subscribed to and return that guard let subscriptions = try await retrieveSubscriptions() else { - throw CommonError.createErrorNotFound + throw AccountError.createErrorNotFound } guard let subscription = subscriptions.first(where: { $0.feedID == subResult.streamID }) else { - throw CommonError.createErrorNotFound + throw AccountError.createErrorNotFound } return .created(subscription) diff --git a/SyncDatabase/Sources/SyncDatabase/Constants.swift b/SyncDatabase/Sources/SyncDatabase/Constants.swift index 86d01b923..44e9bf005 100644 --- a/SyncDatabase/Sources/SyncDatabase/Constants.swift +++ b/SyncDatabase/Sources/SyncDatabase/Constants.swift @@ -11,7 +11,6 @@ import Foundation struct DatabaseTableName { static let syncStatus = "syncStatus" - } struct DatabaseKey { @@ -21,5 +20,4 @@ struct DatabaseKey { static let key = "key" static let flag = "flag" static let selected = "selected" - } diff --git a/SyncDatabase/Sources/SyncDatabase/SyncStatus.swift b/SyncDatabase/Sources/SyncDatabase/SyncStatus.swift index 1b0d66d39..92bf2c135 100644 --- a/SyncDatabase/Sources/SyncDatabase/SyncStatus.swift +++ b/SyncDatabase/Sources/SyncDatabase/SyncStatus.swift @@ -44,5 +44,4 @@ public struct SyncStatus: Hashable, Equatable, Sendable { public func databaseDictionary() -> DatabaseDictionary { return [DatabaseKey.articleID: articleID, DatabaseKey.key: key.rawValue, DatabaseKey.flag: flag, DatabaseKey.selected: selected] } - } diff --git a/iOS/Add/AddFeedViewController.swift b/iOS/Add/AddFeedViewController.swift index 1e2d6685d..1b4605a2d 100644 --- a/iOS/Add/AddFeedViewController.swift +++ b/iOS/Add/AddFeedViewController.swift @@ -106,7 +106,7 @@ class AddFeedViewController: UITableViewController { } if account!.hasFeed(withURL: url.absoluteString) { - presentError(CommonError.createErrorAlreadySubscribed) + presentError(AccountError.createErrorAlreadySubscribed) return } diff --git a/iOS/UIKit Extensions/UIViewController-Extensions.swift b/iOS/UIKit Extensions/UIViewController-Extensions.swift index 6f16099e2..7b90b9118 100644 --- a/iOS/UIKit Extensions/UIViewController-Extensions.swift +++ b/iOS/UIKit Extensions/UIViewController-Extensions.swift @@ -13,7 +13,7 @@ import CommonErrors extension UIViewController { func presentError(_ error: Error, dismiss: (() -> Void)? = nil) { - if let accountError = error as? CommonError, accountError.isCredentialsError { + if let accountError = error as? AccountError, accountError.isCredentialsError { presentAccountError(accountError, dismiss: dismiss) } else if let decodingError = error as? DecodingError { let errorTitle = NSLocalizedString("Error", comment: "Error") @@ -56,7 +56,7 @@ extension UIViewController { private extension UIViewController { - func presentAccountError(_ error: CommonError, dismiss: (() -> Void)? = nil) { + func presentAccountError(_ error: AccountError, dismiss: (() -> Void)? = nil) { let title = NSLocalizedString("Account Error", comment: "Account Error") let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)