diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index a81cf063b..0b1e47fe0 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -238,6 +238,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport) case .feedly: self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment) + case .feedWrangler: + self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) + default: return nil } @@ -315,6 +318,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) case .freshRSS: ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) + case .feedWrangler: + FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) default: break } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index c6089eb13..a835e2639 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; + 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; + 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */; }; + 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */; }; + 3B826DAB2385C81C00FC1ADB /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */; }; + 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */; }; + 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */; }; + 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; + 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; + 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -209,6 +219,16 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; + 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; + 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; + 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; + 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; + 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; + 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItemsRequest.swift; sourceTree = ""; }; + 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; + 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; + 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionResult.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -400,6 +420,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { + isa = PBXGroup; + children = ( + 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */, + 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */, + 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */, + 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */, + 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */, + 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */, + 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */, + 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */, + 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */, + 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */, + ); + path = FeedWrangler; + sourceTree = ""; + }; 5111D71C2357534700737D45 /* Feedbin */ = { isa = PBXGroup; children = ( @@ -551,6 +588,7 @@ 515E4EB12324FF7D0057B0E7 /* Credentials */, 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, + 3B826D9D2385C81C00FC1ADB /* FeedWrangler */, 552032EA229D5D5A009559E0 /* ReaderAPI */, 9EA31339231E368100268BA0 /* Feedly */, 848935031F62484F00CEBD24 /* AccountTests */, @@ -735,8 +773,10 @@ buildConfigurationList = 8489350A1F62485000CEBD24 /* Build configuration list for PBXNativeTarget "Account" */; buildPhases = ( 9E964EBB2375512300A7AF2E /* Run Script: Update OAuthAuthorizationClient+Feedly.swift */, + 3B826DCF2385CE1B00FC1ADB /* Run Script: Update FeedWranglerConfig.swift */, 848934F11F62484F00CEBD24 /* Sources */, 9E964EBC2375517100A7AF2E /* Run Script: Reset OAuthAuthorizationClient+Feedly.swift */, + 3B826DD02385CE9500FC1ADB /* Run Script: Reset FeedWranglerConfig.swift */, 848934F21F62484F00CEBD24 /* Frameworks */, 848934F31F62484F00CEBD24 /* Headers */, 848934F41F62484F00CEBD24 /* Resources */, @@ -891,6 +931,42 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 3B826DCF2385CE1B00FC1ADB /* Run Script: Update FeedWranglerConfig.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Update FeedWranglerConfig.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "FAILED=false\n\nif [ -z \"${FEED_WRANGLER_KEY}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feed Wrangler Key. FeedWranglerConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g; s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g\" \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n\nrm -f \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift.tmp\"\n\necho \"All Feed Wrangler env values found!\"\n\n"; + }; + 3B826DD02385CE9500FC1ADB /* Run Script: Reset FeedWranglerConfig.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Reset FeedWranglerConfig.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "git checkout \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n"; + }; 51C8F34C234FB14B0048ED95 /* Run Script: Verify No Build Settings */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -964,6 +1040,7 @@ 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, @@ -990,11 +1067,14 @@ 9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */, 844B297D2106C7EC004020B3 /* WebFeed.swift in Sources */, + 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */, 9E964EBA23754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift in Sources */, 9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */, 9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */, 9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */, + 3B826DAB2385C81C00FC1ADB /* FeedWranglerConfig.swift in Sources */, 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */, + 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */, 9E672396236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift in Sources */, 9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */, 9EEAE075235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift in Sources */, @@ -1005,6 +1085,7 @@ 9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */, 9EEAE073235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift in Sources */, 9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */, + 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */, 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */, @@ -1013,6 +1094,7 @@ 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */, + 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */, 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */, @@ -1031,6 +1113,7 @@ 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */, 9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */, 9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */, + 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */, 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, @@ -1038,6 +1121,7 @@ 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */, + 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, @@ -1048,6 +1132,8 @@ 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, + 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, + 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index 85d549ac9..bc5ac86ee 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -15,6 +15,8 @@ public enum CredentialsError: Error { public enum CredentialsType: String { case basic = "password" + case feedWranglerBasic = "feedWranglerBasic" + case feedWranglerToken = "feedWranglerToken" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index 4374f285b..1edd0ac8e 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -25,6 +25,14 @@ public extension URLRequest { let base64 = data?.base64EncodedString() let auth = "Basic \(base64 ?? "")" setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .feedWranglerBasic: + self.url = url.appendingQueryItems([ + URLQueryItem(name: "email", value: credentials.username), + URLQueryItem(name: "password", value: credentials.secret), + URLQueryItem(name: "client_key", value: FeedWranglerConfig.clientKey) + ]) + case .feedWranglerToken: + self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift new file mode 100644 index 000000000..65c2e41cb --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -0,0 +1,291 @@ +// +// FeedWranglerAPICaller.swift +// Account +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +import Foundation +import SyncDatabase +import RSWeb + +enum FeedWranglerError : Error { + case general(message: String) +} + +final class FeedWranglerAPICaller: NSObject { + + private var transport: Transport! + + var credentials: Credentials? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport) { + super.init() + self.transport = transport + } + + func cancelAll() { + transport.cancelAll() + } + + func logout(completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/logout") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func validateCredentials(completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") + let username = self.credentials?.username ?? "" + + standardSend(url: url, resultType: FeedWranglerAuthorizationResult.self) { result in + switch result { + case .success(let (_, results)): + if let accessToken = results?.accessToken { + let authCredentials = Credentials(type: .feedWranglerToken, username: username, secret: accessToken) + completion(.success(authCredentials)) + } else { + completion(.success(nil)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveSubscriptions(completion: @escaping (Result<[FeedWranglerSubscription], Error>) -> Void) { + let url = FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/list") + + standardSend(url: url, resultType: FeedWranglerSubscriptionsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feeds ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func addSubscription(url: String, completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig + .clientURL + .appendingPathComponent("subscriptions/add_feed_and_wait") + .appendingQueryItems([ + URLQueryItem(name: "feed_url", value: url), + URLQueryItem(name: "choose_first", value: "true") + ]) + + standardSend(url: url, resultType: FeedWranglerSubscriptionResult.self) { result in + switch result { + case .success(let (_, results)): + if let results = results { + if let error = results.error { + completion(.failure(FeedWranglerError.general(message: error))) + } else { + completion(.success(results.feed)) + } + } else { + completion(.failure(FeedWranglerError.general(message: "No feed found"))) + } + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func renameSubscription(feedID: String, newName: String, completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("subscriptions/rename_feed") + .appendingQueryItems([ + URLQueryItem(name: "feed_id", value: feedID), + URLQueryItem(name: "feed_name", value: newName), + ]) + + standardSend(url: url, resultType: FeedWranglerSubscriptionsRequest.self) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func removeSubscription(feedID: String, completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("subscriptions/remove_feed") + .appendingQueryItem(URLQueryItem(name: "feed_id", value: feedID)) + + standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: FeedItems + func retrieveEntries(articleIDs: [String], completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let IDs = articleIDs.joined(separator: ",") + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/get") + .appendingQueryItem(URLQueryItem(name: "feed_item_ids", value: IDs)) + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func retrieveFeedItems(page: Int = 0, feed: WebFeed? = nil, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let queryItems = [ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + feed.map { URLQueryItem(name: "feed_id", value: $0.webFeedID) } + ].compactMap { $0 } + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems(queryItems) + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveUnreadFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems([ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + ]) + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveAllUnreadFeedItems(foundItems: [FeedWranglerFeedItem] = [], page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + retrieveUnreadFeedItems(page: page) { result in + switch result { + case .success(let newItems): + if newItems.count > 0 { + self.retrieveAllUnreadFeedItems(foundItems: foundItems + newItems, page: (page + 1), completion: completion) + } else { + completion(.success(foundItems + newItems)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveStarredFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems([ + URLQueryItem(name: "starred", value: "true"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + ]) + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveAllStarredFeedItems(foundItems: [FeedWranglerFeedItem] = [], page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + retrieveStarredFeedItems(page: page) { result in + switch result { + case .success(let newItems): + if newItems.count > 0 { + self.retrieveAllStarredFeedItems(foundItems: foundItems + newItems, page: (page + 1), completion: completion) + } else { + completion(.success(foundItems + newItems)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func updateArticleStatus(_ articleID: String, _ statuses: [SyncStatus], completion: @escaping () -> Void) { + + var queryItems = statuses.compactMap { status -> URLQueryItem? in + switch status.key { + case .read: + return URLQueryItem(name: "read", value: status.flag.description) + + case .starred: + return URLQueryItem(name: "starred", value: status.flag.description) + + case .userDeleted: + return nil + } + } + queryItems.append(URLQueryItem(name: "feed_item_id", value: articleID)) + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/update") + .appendingQueryItems(queryItems) + + standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in + completion() + } + } + + private func standardSend(url: URL?, resultType: R.Type, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request, resultType: resultType, completion: completion) + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift new file mode 100644 index 000000000..f1b4352f4 --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -0,0 +1,537 @@ +// +// FeedWranglerAccountDelegate.swift +// Account +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSParser +import RSWeb +import SyncDatabase +import os.log + +final class FeedWranglerAccountDelegate: AccountDelegate { + + var behaviors: AccountBehaviors = [] + + var isOPMLImportInProgress = false + var server: String? = FeedWranglerConfig.clientPath + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + var accountMetadata: AccountMetadata? + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + private let caller: FeedWranglerAPICaller + private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feed Wrangler") + private let database: SyncDatabase + + init(dataFolder: String, transport: Transport?) { + if let transport = transport { + caller = FeedWranglerAPICaller(transport: transport) + } else { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + let session = URLSession(configuration: sessionConfiguration) + caller = FeedWranglerAPICaller(transport: session) + } + + database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) + } + + func accountWillBeDeleted(_ account: Account) { + caller.logout() { _ in } + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(6) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.refreshSubscriptions(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.sendArticleStatus(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshMissingArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + func cancelAll(for account: Account) { + caller.cancelAll() + } + + func refreshCredentials(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing credentials...") + // MARK: TODO + credentials = try? account.retrieveCredentials(type: .feedWranglerToken) + completion() + } + + func refreshSubscriptions(for account: Account, completion: @escaping ((Result) -> Void)) { + os_log(.debug, log: log, "Refreshing subscriptions...") + caller.retrieveSubscriptions { result in + switch result { + case .success(let subscriptions): + self.syncFeeds(account, subscriptions) + completion(.success(())) + + case .failure(let error): + os_log(.debug, log: self.log, "Failed to refresh subscriptions: %@", error.localizedDescription) + completion(.failure(error)) + } + + } + } + + func refreshArticles(for account: Account, page: Int = 0, completion: @escaping ((Result) -> Void)) { + os_log(.debug, log: log, "Refreshing articles, page: %d...", page) + + caller.retrieveFeedItems(page: page) { result in + switch result { + case .success(let items): + self.syncFeedItems(account, items) { + if items.count == 0 { + completion(.success(())) + } else { + self.refreshArticles(for: account, page: (page + 1), completion: completion) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func refreshMissingArticles(for account: Account, completion: @escaping ((Result)-> Void)) { + os_log(.debug, log: log, "Refreshing missing articles...") + let group = DispatchGroup() + + let fetchedArticleIDs = account.fetchArticleIDsForStatusesWithoutArticles() + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + group.enter() + self.caller.retrieveEntries(articleIDs: chunk) { result in + switch result { + case .success(let entries): + self.syncFeedItems(account, entries) { + group.leave() + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh missing articles failed: %@", error.localizedDescription) + group.leave() + } + } + } + + group.notify(queue: DispatchQueue.main) { + self.refreshProgress.completeTask() + os_log(.debug, log: self.log, "Done refreshing missing articles.") + completion(.success(())) + } + } + + func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + os_log(.debug, log: log, "Sending article status...") + + let syncStatuses = database.selectForProcessing() + let articleStatuses = Dictionary(grouping: syncStatuses, by: { $0.articleID }) + let group = DispatchGroup() + + articleStatuses.forEach { articleID, statuses in + group.enter() + caller.updateArticleStatus(articleID, statuses) { + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done sending article statuses.") + completion(.success(())) + } + } + + func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + os_log(.debug, log: log, "Refreshing article status...") + let group = DispatchGroup() + + group.enter() + caller.retrieveAllUnreadFeedItems { result in + switch result { + case .success(let items): + self.syncArticleReadState(account, items) + group.leave() + + case .failure(let error): + os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) + group.leave() + } + } + + // starred + group.enter() + caller.retrieveAllStarredFeedItems { result in + switch result { + case .success(let items): + self.syncArticleStarredState(account, items) + group.leave() + + case .failure(let error): + os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription) + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done refreshing article statuses.") + completion(.success(())) + } + } + + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + fatalError() + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(2) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.addSubscription(url: url) { result in + self.refreshProgress.completeTask() + + switch result { + case .success(let subscription): + self.addFeedWranglerSubscription(account: account, subscription: subscription, name: name, container: container, completion: completion) + + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + } + + private func addFeedWranglerSubscription(account: Account, subscription sub: FeedWranglerSubscription, name: String?, container: Container, completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { + let feed = account.createWebFeed(with: sub.title, url: sub.feedURL, webFeedID: String(sub.feedID), homePageURL: sub.siteURL) + + account.addWebFeed(feed, to: container) { result in + switch result { + case .success: + if let name = name { + account.renameWebFeed(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): + completion(.failure(error)) + } + } + } + } + + private func initialFeedDownload(account: Account, feed: WebFeed, completion: @escaping (Result) -> Void) { + + self.caller.retrieveFeedItems(page: 0, feed: feed) { results in + switch results { + case .success(let entries): + self.syncFeedItems(account, entries) { + DispatchQueue.main.async { + completion(.success(feed)) + } + } + + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(2) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.renameSubscription(feedID: feed.webFeedID, newName: name) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + feed.editedName = name + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } + } + + func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { + // just add to account, folders are not supported + DispatchQueue.main.async { + account.addFeedIfNotInAnyFolder(feed) + completion(.success(())) + } + } + + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(2) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.removeSubscription(feedID: feed.webFeedID) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + account.clearWebFeedMetadata(feed) + account.removeWebFeed(feed) + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } + } + + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + let syncStatuses = articles.map { SyncStatus(articleID: $0.articleID, key: statusKey, flag: flag)} + database.insertStatuses(syncStatuses) + + if database.selectPendingCount() > 0 { + sendArticleStatus(for: account) { _ in + // do it in the background + } + } + + return account.update(articles, statusKey: statusKey, flag: flag) + } + + func accountDidInitialize(_ account: Account) { + credentials = try? account.retrieveCredentials(type: .feedWranglerToken) + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> Void) { + let caller = FeedWranglerAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials() { result in + DispatchQueue.main.async { + completion(result) + } + } + } +} + +// MARK: Private +private extension FeedWranglerAccountDelegate { + + func syncFeeds(_ account: Account, _ subscriptions: [FeedWranglerSubscription]) { + assert(Thread.isMainThread) + let feedIds = subscriptions.map { String($0.feedID) } + + let feedsToRemove = account.topLevelWebFeeds.filter { !feedIds.contains($0.webFeedID) } + account.removeFeeds(feedsToRemove) + + var subscriptionsToAdd = Set() + subscriptions.forEach { subscription in + let subscriptionId = String(subscription.feedID) + + if let feed = account.existingWebFeed(withWebFeedID: subscriptionId) { + feed.name = subscription.title + feed.editedName = nil + feed.homePageURL = subscription.siteURL + feed.subscriptionID = nil // MARK: TODO What should this be? + } else { + subscriptionsToAdd.insert(subscription) + } + } + + subscriptionsToAdd.forEach { subscription in + let feedId = String(subscription.feedID) + let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: feedId, homePageURL: subscription.siteURL) + feed.subscriptionID = nil + account.addWebFeed(feed) + } + } + + func syncFeedItems(_ account: Account, _ feedItems: [FeedWranglerFeedItem], completion: @escaping (() -> Void)) { + let parsedItems = feedItems.map { (item: FeedWranglerFeedItem) -> ParsedItem in + let itemID = String(item.feedItemID) + // let authors = ... + let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) + + return parsedItem + } + + let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { $0.feedURL }).mapValues { Set($0) } + account.update(webFeedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion) + } + + func syncArticleReadState(_ account: Account, _ unreadFeedItems: [FeedWranglerFeedItem]) { + let unreadServerItemIDs = Set(unreadFeedItems.map { String($0.feedItemID) }) + let unreadLocalItemIDs = account.fetchUnreadArticleIDs() + + // unread if unread on server + let unreadDiffItemIDs = unreadServerItemIDs.subtracting(unreadLocalItemIDs) + let unreadFoundArticles = account.fetchArticles(.articleIDs(unreadDiffItemIDs)) + account.update(unreadFoundArticles, statusKey: .read, flag: false) + + let unreadFoundItemIDs = Set(unreadFoundArticles.map { $0.articleID }) + let missingArticleIDs = unreadDiffItemIDs.subtracting(unreadFoundItemIDs) + account.ensureStatuses(missingArticleIDs, true, .read, false) + + let readItemIDs = unreadLocalItemIDs.subtracting(unreadServerItemIDs) + let readArtices = account.fetchArticles(.articleIDs(readItemIDs)) + account.update(readArtices, statusKey: .read, flag: true) + + let foundReadArticleIDs = Set(readArtices.map { $0.articleID }) + let readMissingIDs = readItemIDs.subtracting(foundReadArticleIDs) + account.ensureStatuses(readMissingIDs, true, .read, true) + } + + func syncArticleStarredState(_ account: Account, _ unreadFeedItems: [FeedWranglerFeedItem]) { + let unreadServerItemIDs = Set(unreadFeedItems.map { String($0.feedItemID) }) + let unreadLocalItemIDs = account.fetchUnreadArticleIDs() + + // starred if start on server + let unreadDiffItemIDs = unreadServerItemIDs.subtracting(unreadLocalItemIDs) + let unreadFoundArticles = account.fetchArticles(.articleIDs(unreadDiffItemIDs)) + account.update(unreadFoundArticles, statusKey: .starred, flag: true) + + let unreadFoundItemIDs = Set(unreadFoundArticles.map { $0.articleID }) + let missingArticleIDs = unreadDiffItemIDs.subtracting(unreadFoundItemIDs) + account.ensureStatuses(missingArticleIDs, true, .starred, true) + + let readItemIDs = unreadLocalItemIDs.subtracting(unreadServerItemIDs) + let readArtices = account.fetchArticles(.articleIDs(readItemIDs)) + account.update(readArtices, statusKey: .starred, flag: false) + + let foundReadArticleIDs = Set(readArtices.map { $0.articleID }) + let readMissingIDs = readItemIDs.subtracting(foundReadArticleIDs) + account.ensureStatuses(readMissingIDs, true, .starred, false) + } + + func syncArticleState(_ account: Account, key: ArticleStatus.Key, flag: Bool, serverFeedItems: [FeedWranglerFeedItem]) { + let serverFeedItemIDs = serverFeedItems.map { String($0.feedID) } + + // todo generalize this logic + } +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift new file mode 100644 index 000000000..5055b4a0b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift @@ -0,0 +1,23 @@ +// +// FeedWranglerAuthorizationResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-11-20. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerAuthorizationResult: Hashable, Codable { + + let accessToken: String? + let error: String? + let result: String + + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case error = "error" + case result = "result" + } +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift new file mode 100644 index 000000000..16205707f --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift @@ -0,0 +1,18 @@ +// +// FeedWranglerConfig.swift +// NetNewsWire +// +// Created by Jonathan Bennett on 9/27/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum FeedWranglerConfig { + static let pageSize = 100 + static let clientKey = "{FEEDWRANGLERKEY}" // Add FEED_WRANGLER_KEY = XYZ to SharedXcodeSettings/DeveloperSettings.xcconfig + static let clientPath = "https://feedwrangler.net/api/v2/" + static let clientURL = { + URL(string: FeedWranglerConfig.clientPath)! + }() +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift new file mode 100644 index 000000000..28389b292 --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift @@ -0,0 +1,62 @@ +// +// FeedWranglerFeedItem.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16.4// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerFeedItem: Hashable, Codable { + + let feedItemID: Int + let publishedAt: Int + let createdAt: Int + let versionKey: Int + let updatedAt: Int + let url: String + let title: String + let starred: Bool + let read: Bool + let readLater: Bool + let body: String + let author: String? + let feedID: Int + let feedName: String + + var publishedDate: Date { + get { + Date(timeIntervalSince1970: Double(publishedAt)) + } + } + + var createdDate: Date { + get { + Date(timeIntervalSince1970: Double(createdAt)) + } + } + + var updatedDate: Date { + get { + Date(timeIntervalSince1970: Double(updatedAt)) + } + } + + enum CodingKeys: String, CodingKey { + case feedItemID = "feed_item_id" + case publishedAt = "published_at" + case createdAt = "created_at" + case versionKey = "version_key" + case updatedAt = "updated_at" + case url = "url" + case title = "title" + case starred = "starred" + case read = "read" + case readLater = "read_later" + case body = "body" + case author = "author" + case feedID = "feed_id" + case feedName = "feed_name" + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift new file mode 100644 index 000000000..426aae6d3 --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift @@ -0,0 +1,25 @@ +// +// FeedWranglerFeedItemsRequest.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerFeedItemsRequest: Hashable, Codable { + + let count: Int + let feedItems: [FeedWranglerFeedItem] + let error: String? + let result: String + + enum CodingKeys: String, CodingKey { + case count = "count" + case feedItems = "feed_items" + case error = "error" + case result = "result" + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift new file mode 100644 index 000000000..817fe9c8b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift @@ -0,0 +1,16 @@ +// +// FeedWranglerGenericResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerGenericResult: Hashable, Codable { + + let error: String? + let result: String + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift new file mode 100644 index 000000000..821dd41ad --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift @@ -0,0 +1,26 @@ +// +// FeedWranglerSubscription.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// +import Foundation +import RSCore +import RSParser + +struct FeedWranglerSubscription: Hashable, Codable { + + let title: String + let feedID: Int + let feedURL: String + let siteURL: String? + + enum CodingKeys: String, CodingKey { + case title = "title" + case feedID = "feed_id" + case feedURL = "feed_url" + case siteURL = "site_url" + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift new file mode 100644 index 000000000..2bd21bbee --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift @@ -0,0 +1,18 @@ +// +// FeedWranglerSubscriptionResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-11-20. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerSubscriptionResult: Hashable, Codable { + + let feed: FeedWranglerSubscription + let error: String? + let result: String + +} + diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift new file mode 100644 index 000000000..66fb6a20b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift @@ -0,0 +1,17 @@ +// +// FeedWranglerSubscriptionsRequest.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerSubscriptionsRequest: Hashable, Codable { + + let feeds: [FeedWranglerSubscription] + let error: String? + let result: String + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift index e37bb211f..152cb3415 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift @@ -879,18 +879,15 @@ final class ReaderAPICaller: NSObject { return } - guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } + let url = baseURL + .appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue) + .appendingQueryItems([ + URLQueryItem(name: "s", value: "user/-/state/com.google/starred"), + URLQueryItem(name: "n", value: "10000"), + URLQueryItem(name: "output", value: "json") + ]) - components.queryItems = [ - URLQueryItem(name: "s", value: "user/-/state/com.google/starred"), - URLQueryItem(name: "n", value: "10000"), - URLQueryItem(name: "output", value: "json") - ] - - guard let callURL = components.url else { + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index cf157c2cc..4c01f583b 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -33,6 +33,10 @@ struct AppAssets { return RSImage(named: "accountFeedly") }() + static var accountFeedWrangler: RSImage! = { + return RSImage(named: "accountFeedWrangler") + }() + static var accountFreshRSS: RSImage! = { return RSImage(named: "accountFreshRSS") }() @@ -125,6 +129,8 @@ struct AppAssets { return AppAssets.accountFeedbin case .feedly: return AppAssets.accountFeedly + case .feedWrangler: + return AppAssets.accountFeedWrangler case .freshRSS: return AppAssets.accountFreshRSS default: diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index db0def90c..bfd0050b8 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .freshRSS] + private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS] init() { super.init(nibName: "AccountsAdd", bundle: nil) @@ -65,6 +65,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .feedbin: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedbin", comment: "Feedbin") cell.accountImageView?.image = AppAssets.accountFeedbin + case .feedWrangler: + cell.accountNameLabel?.stringValue = NSLocalizedString("Feed Wrangler", comment: "Feed Wrangler") + cell.accountImageView?.image = AppAssets.accountFeedWrangler case .freshRSS: cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS") cell.accountImageView?.image = AppAssets.accountFreshRSS @@ -95,6 +98,10 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsFeedbinWindowController = AccountsFeedbinWindowController() accountsFeedbinWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsFeedbinWindowController + case .feedWrangler: + let accountsFeedWranglerWindowController = AccountsFeedWranglerWindowController() + accountsFeedWranglerWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsFeedWranglerWindowController case .freshRSS: let accountsReaderAPIWindowController = AccountsReaderAPIWindowController() accountsReaderAPIWindowController.accountType = .freshRSS diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index 4a04625f7..a33d7bc66 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -80,6 +80,11 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate accountsFreshRSSWindowController.runSheetOnWindow(self.view.window!) accountsWindowController = accountsFreshRSSWindowController break + case .feedWrangler: + let accountsFeedWranglerWindowController = AccountsFeedWranglerWindowController() + accountsFeedWranglerWindowController.account = account + accountsFeedWranglerWindowController.runSheetOnWindow(self.view.window!) + accountsWindowController = accountsFeedWranglerWindowController default: break } diff --git a/Mac/Preferences/Accounts/AccountsFeedWrangler.xib b/Mac/Preferences/Accounts/AccountsFeedWrangler.xib new file mode 100644 index 000000000..684cdb225 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedWrangler.xib @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift new file mode 100644 index 000000000..c4b4f6e1e --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift @@ -0,0 +1,110 @@ +// +// AccountsFeedWranglerWindowController.swift +// NetNewsWire +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsFeedWranglerWindowController: NSWindowController { + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: NSTextField! + @IBOutlet weak var passwordTextField: NSSecureTextField! + @IBOutlet weak var errorMessageLabel: NSTextField! + @IBOutlet weak var actionButton: NSButton! + + var account: Account? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsFeedWrangler")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { + usernameTextField.stringValue = credentials.username + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: handler) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + self.errorMessageLabel.stringValue = "" + + guard !usernameTextField.stringValue.isEmpty && !passwordTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username & password required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + let credentials = Credentials(type: .feedWranglerBasic, username: usernameTextField.stringValue, secret: passwordTextField.stringValue) + Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.actionButton.isEnabled = true + self.progressIndicator.isHidden = true + self.progressIndicator.stopAnimation(self) + + switch result { + case .success(let validatedCredentials): + guard let validatedCredentials = validatedCredentials else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + return + } + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .feedWrangler) + newAccount = true + } + + do { + try self.account?.removeCredentials(type: .feedWranglerBasic) + try self.account?.removeCredentials(type: .feedWranglerToken) + try self.account?.storeCredentials(credentials) + try self.account?.storeCredentials(validatedCredentials) + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + case .failure: + + self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") + + } + } + } +} diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 5c5022610..2c1212a98 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -103,18 +103,7 @@ extension AccountsPreferencesViewController: NSTableViewDelegate { if let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "Cell"), owner: nil) as? NSTableCellView { let account = sortedAccounts[row] cell.textField?.stringValue = account.nameForDisplay - switch account.type { - case .onMyMac: - cell.imageView?.image = AppAssets.accountLocal - case .feedbin: - cell.imageView?.image = AppAssets.accountFeedbin - case .freshRSS: - cell.imageView?.image = AppAssets.accountFreshRSS - case .feedly: - cell.imageView?.image = AppAssets.accountFeedly - default: - break - } + cell.imageView?.image = account.smallIcon?.image return cell } return nil diff --git a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json new file mode 100644 index 000000000..0721d5d1f --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "outline-512.png" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/outline-512.png b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/outline-512.png new file mode 100644 index 000000000..f7d29faa1 Binary files /dev/null and b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/outline-512.png differ diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 0c0408a50..bd3a5a57e 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; + 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; + 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; + 3B826DCE2385C89600FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; }; 49F40DF92335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; }; 5108F6B62375E612001ABC45 /* CacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6B52375E612001ABC45 /* CacheCleaner.swift */; }; @@ -1215,6 +1219,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = ""; }; + 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = ""; }; 49F40DEF2335B71000552BF4 /* newsfoot.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = newsfoot.js; sourceTree = ""; }; 5108F6B52375E612001ABC45 /* CacheCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheCleaner.swift; sourceTree = ""; }; 5108F6D12375EED2001ABC45 /* TimelineCustomizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineCustomizerViewController.swift; sourceTree = ""; }; @@ -2522,6 +2528,8 @@ 5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */, 5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */, 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */, + 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */, + 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */, 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */, 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */, 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */, @@ -2952,7 +2960,7 @@ }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 8EQFQ9RY84; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { @@ -2962,7 +2970,7 @@ TestTargetID = 840D617B2029031C009BC708; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 8EQFQ9RY84; ProvisioningStyle = Automatic; }; 65ED3FA2235DEF6C0081F399 = { @@ -2975,7 +2983,7 @@ }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 8EQFQ9RY84; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -2985,7 +2993,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 8EQFQ9RY84; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -2995,7 +3003,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 8EQFQ9RY84; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -3393,6 +3401,7 @@ 65ED4066235DEF6C0081F399 /* TimelineTableView.xib in Resources */, 65ED4067235DEF6C0081F399 /* page.html in Resources */, 65ED4068235DEF6C0081F399 /* MainWindow.storyboard in Resources */, + 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */, 65ED4069235DEF6C0081F399 /* AccountsReaderAPI.xib in Resources */, 65ED406A235DEF6C0081F399 /* newsfoot.js in Resources */, 65ED406B235DEF6C0081F399 /* CrashReporterWindow.xib in Resources */, @@ -3478,6 +3487,7 @@ 8405DDA222168920008CE1BF /* TimelineTableView.xib in Resources */, B528F81E23333C7E00E735DD /* page.html in Resources */, 8483630E2262A3FE00DA1D35 /* MainWindow.storyboard in Resources */, + 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */, 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */, 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, @@ -3829,6 +3839,7 @@ 65ED3FFF235DEF6C0081F399 /* SidebarOutlineDataSource.swift in Sources */, 65ED4000235DEF6C0081F399 /* SidebarCellAppearance.swift in Sources */, 65ED4001235DEF6C0081F399 /* StarredFeedDelegate.swift in Sources */, + 3B826DCE2385C89600FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, 65ED4002235DEF6C0081F399 /* FaviconDownloader.swift in Sources */, 65ED4003235DEF6C0081F399 /* AdvancedPreferencesViewController.swift in Sources */, 65ED4004235DEF6C0081F399 /* SharingServicePickerDelegate.swift in Sources */, @@ -4151,6 +4162,7 @@ 84CAFCAF22BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, 8426119E1FCB6ED40086A189 /* HTMLMetadataDownloader.swift in Sources */, 849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */, + 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, 5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */, 5183CCE6226F4E110010922C /* RefreshInterval.swift in Sources */, 849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */,