diff --git a/.gitignore b/.gitignore index 51163b4ba..98647dfa5 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,6 @@ fastlane/screenshots fastlane/test_output - /Shared/Secrets.swift +/Frameworks/Secrets/Secrets.swift *.py[cod] diff --git a/.gitmodules b/.gitmodules index 9ddb37391..12029fc21 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,6 @@ path = submodules/Sparkle url = https://github.com/brentsimmons/Sparkle branch = ui-separation-and-xpc +[submodule "submodules/OAuthSwift"] + path = submodules/OAuthSwift + url = https://github.com/Ranchero-Software/OAuthSwift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 7bbf644a7..66927d935 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -18,6 +18,7 @@ import RSDatabase import ArticlesDatabase import RSWeb import os.log +import Secrets // Main thread only. @@ -749,9 +750,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, database.update(with: parsedItems, webFeedID: webFeedID) { updateArticlesResult in switch updateArticlesResult { - case .success(let newAndUpdatedArticles): - self.sendNotificationAbout(newAndUpdatedArticles) - completion(.success(newAndUpdatedArticles)) + case .success(let articleChanges): + self.sendNotificationAbout(articleChanges) + completion(.success(articleChanges)) case .failure(let databaseError): completion(.failure(databaseError)) } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 38efd6984..5f2eaa40e 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; - 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 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 */; }; @@ -28,14 +27,20 @@ 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 */; }; + 5102FD80244009E000534F17 /* Secrets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5102FD7F244009E000534F17 /* Secrets.framework */; }; 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; }; + 510E3317244E0CED00E7A6AF /* TwitterMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510E3316244E0CED00E7A6AF /* TwitterMedia.swift */; }; 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; }; 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */; }; 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */; }; + 5132AAC42448BAD90077840A /* FeedProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5132AAC12448BAD90077840A /* FeedProvider.swift */; }; + 5132AAC52448BAD90077840A /* TwitterFeedProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5132AAC32448BAD90077840A /* TwitterFeedProvider.swift */; }; + 5132DE812449159100806ADE /* TwitterUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5132DE802449159100806ADE /* TwitterUser.swift */; }; + 5132DE832449306F00806ADE /* TwitterStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5132DE822449306F00806ADE /* TwitterStatus.swift */; }; 513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; }; 5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; }; 5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; }; @@ -44,9 +49,7 @@ 514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; }; 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; - 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */; }; - 515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB42324FF8C0057B0E7 /* Credentials.swift */; }; 5165D7122282080C00D9D53D /* AccountFeedbinFolderContentsSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D7112282080C00D9D53D /* AccountFeedbinFolderContentsSyncTest.swift */; }; 5165D71622821C2400D9D53D /* taggings_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71322821C2400D9D53D /* taggings_delete.json */; }; 5165D71722821C2400D9D53D /* taggings_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5165D71422821C2400D9D53D /* taggings_add.json */; }; @@ -55,10 +58,20 @@ 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71D22835E9800D9D53D /* FeedSpecifier.swift */; }; 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */; }; 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; }; + 516896352448EBEA00185AC5 /* FeedProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516896342448EBEA00185AC5 /* FeedProviderManager.swift */; }; 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; + 51B36305244B6135000DEF2A /* TwitterEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36304244B6135000DEF2A /* TwitterEntities.swift */; }; + 51B36307244B6234000DEF2A /* TwitterHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36306244B6234000DEF2A /* TwitterHashtag.swift */; }; + 51B36309244B62A5000DEF2A /* TwitterURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36308244B62A5000DEF2A /* TwitterURL.swift */; }; + 51B3630B244B634A000DEF2A /* TwitterMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B3630A244B634A000DEF2A /* TwitterMention.swift */; }; + 51B3630D244B6428000DEF2A /* TwitterSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B3630C244B6428000DEF2A /* TwitterSymbol.swift */; }; + 51B3630F244B6CB9000DEF2A /* TwitterExtendedEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B3630E244B6CB9000DEF2A /* TwitterExtendedEntities.swift */; }; + 51B36311244B6CFB000DEF2A /* TwitterExtendedMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36310244B6CFA000DEF2A /* TwitterExtendedMedia.swift */; }; + 51B36313244B8B5E000DEF2A /* TwitterVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36312244B8B5E000DEF2A /* TwitterVideo.swift */; }; + 51B36315244BCCA4000DEF2A /* TwitterSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B36314244BCCA4000DEF2A /* TwitterSearchResult.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -251,7 +264,6 @@ 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = ""; }; 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAPICaller+Internal.swift"; sourceTree = ""; }; 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = ""; }; - 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 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 = ""; }; @@ -262,15 +274,23 @@ 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 = ""; }; + 5102FD7F244009E000534F17 /* Secrets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Secrets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountDelegate.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 = ""; }; 510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = ""; }; 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = ""; }; + 510E3316244E0CED00E7A6AF /* TwitterMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterMedia.swift; sourceTree = ""; }; + 511076A3243BD33100D97C8C /* .framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = .framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 511076F4243BD96D00D97C8C /* FeedProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FeedProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = ""; }; 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+Extensions.swift"; sourceTree = ""; }; 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZoneDelegate.swift; sourceTree = ""; }; + 5132AAC12448BAD90077840A /* FeedProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedProvider.swift; sourceTree = ""; }; + 5132AAC32448BAD90077840A /* TwitterFeedProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TwitterFeedProvider.swift; sourceTree = ""; }; + 5132DE802449159100806ADE /* TwitterUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterUser.swift; sourceTree = ""; }; + 5132DE822449306F00806ADE /* TwitterStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterStatus.swift; sourceTree = ""; }; 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = ""; }; 513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = ""; }; 5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = ""; }; @@ -279,9 +299,7 @@ 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = ""; }; 5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; - 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; - 515E4EB42324FF8C0057B0E7 /* Credentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; 5165D7112282080C00D9D53D /* AccountFeedbinFolderContentsSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinFolderContentsSyncTest.swift; sourceTree = ""; }; 5165D71322821C2400D9D53D /* taggings_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_delete.json; sourceTree = ""; }; 5165D71422821C2400D9D53D /* taggings_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = taggings_add.json; sourceTree = ""; }; @@ -290,11 +308,21 @@ 5165D71D22835E9800D9D53D /* FeedSpecifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedSpecifier.swift; sourceTree = ""; }; 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLFeedFinder.swift; sourceTree = ""; }; 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = ""; }; + 516896342448EBEA00185AC5 /* FeedProviderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedProviderManager.swift; sourceTree = ""; }; 5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = ""; }; 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; + 51B36304244B6135000DEF2A /* TwitterEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterEntities.swift; sourceTree = ""; }; + 51B36306244B6234000DEF2A /* TwitterHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterHashtag.swift; sourceTree = ""; }; + 51B36308244B62A5000DEF2A /* TwitterURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterURL.swift; sourceTree = ""; }; + 51B3630A244B634A000DEF2A /* TwitterMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterMention.swift; sourceTree = ""; }; + 51B3630C244B6428000DEF2A /* TwitterSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterSymbol.swift; sourceTree = ""; }; + 51B3630E244B6CB9000DEF2A /* TwitterExtendedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterExtendedEntities.swift; sourceTree = ""; }; + 51B36310244B6CFA000DEF2A /* TwitterExtendedMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterExtendedMedia.swift; sourceTree = ""; }; + 51B36312244B8B5E000DEF2A /* TwitterVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterVideo.swift; sourceTree = ""; }; + 51B36314244BCCA4000DEF2A /* TwitterSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterSearchResult.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -463,6 +491,7 @@ 51E148EC234B8FFC0004F7A5 /* SyncDatabase.framework in Frameworks */, 841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */, 841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */, + 5102FD80244009E000534F17 /* Secrets.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -544,14 +573,34 @@ path = Feedbin; sourceTree = ""; }; - 515E4EB12324FF7D0057B0E7 /* Credentials */ = { + 5132AABB2448BA5B0077840A /* FeedProvider */ = { isa = PBXGroup; children = ( - 515E4EB42324FF8C0057B0E7 /* Credentials.swift */, - 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */, - 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */, + 5132AAC12448BAD90077840A /* FeedProvider.swift */, + 516896342448EBEA00185AC5 /* FeedProviderManager.swift */, + 5132AAC22448BAD90077840A /* Twitter */, ); - path = Credentials; + path = FeedProvider; + sourceTree = ""; + }; + 5132AAC22448BAD90077840A /* Twitter */ = { + isa = PBXGroup; + children = ( + 51B36304244B6135000DEF2A /* TwitterEntities.swift */, + 51B3630E244B6CB9000DEF2A /* TwitterExtendedEntities.swift */, + 5132AAC32448BAD90077840A /* TwitterFeedProvider.swift */, + 51B36306244B6234000DEF2A /* TwitterHashtag.swift */, + 51B36310244B6CFA000DEF2A /* TwitterExtendedMedia.swift */, + 51B3630A244B634A000DEF2A /* TwitterMention.swift */, + 51B36314244BCCA4000DEF2A /* TwitterSearchResult.swift */, + 5132DE822449306F00806ADE /* TwitterStatus.swift */, + 51B3630C244B6428000DEF2A /* TwitterSymbol.swift */, + 51B36308244B62A5000DEF2A /* TwitterURL.swift */, + 5132DE802449159100806ADE /* TwitterUser.swift */, + 51B36312244B8B5E000DEF2A /* TwitterVideo.swift */, + 510E3316244E0CED00E7A6AF /* TwitterMedia.swift */, + ); + path = Twitter; sourceTree = ""; }; 5165D71F22835E9800D9D53D /* FeedFinder */ = { @@ -657,6 +706,9 @@ 8469F80F1F6DC3C10084783E /* Frameworks */ = { isa = PBXGroup; children = ( + 5102FD7F244009E000534F17 /* Secrets.framework */, + 511076F4243BD96D00D97C8C /* FeedProvider.framework */, + 511076A3243BD33100D97C8C /* .framework */, 51E148EB234B8FFC0004F7A5 /* SyncDatabase.framework */, 84EAC4812148CC6300F154AB /* RSDatabase.framework */, 844B2980210CE3BF004020B3 /* RSWeb.framework */, @@ -671,7 +723,6 @@ 848934EC1F62484F00CEBD24 = { isa = PBXGroup; children = ( - 3B3A33E6238D3D6800314204 /* Secrets.swift */, 848935101F62486800CEBD24 /* Account.swift */, 841974241F6DDCE4006346C4 /* AccountDelegate.swift */, 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */, @@ -692,23 +743,24 @@ 511B9803237CD4270028BCAA /* FeedIdentifier.swift */, 841974001F6DD1EC006346C4 /* Folder.swift */, 844B297E210CE37E004020B3 /* UnreadCountProvider.swift */, + 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */, 844B297C2106C7EC004020B3 /* WebFeed.swift */, 84B2D4CE2238C13D00498ADA /* WebFeedMetadata.swift */, 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */, 5165D71F22835E9800D9D53D /* FeedFinder */, - 515E4EB12324FF7D0057B0E7 /* Credentials */, + 5132AABB2448BA5B0077840A /* FeedProvider */, 8419742B1F6DDE84006346C4 /* LocalAccount */, - 84245C7D1FDDD2580074AFBB /* Feedbin */, - 3B826D9D2385C81C00FC1ADB /* FeedWrangler */, - 552032EA229D5D5A009559E0 /* ReaderAPI */, - 9EA31339231E368100268BA0 /* Feedly */, 5103A9D7242253DC00410853 /* CloudKit */, + 84245C7D1FDDD2580074AFBB /* Feedbin */, + 9EA31339231E368100268BA0 /* Feedly */, + 3B826D9D2385C81C00FC1ADB /* FeedWrangler */, + 769F2630AF8DC873D4A73567 /* NewsBlur */, + 552032EA229D5D5A009559E0 /* ReaderAPI */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, D511EEB4202422BB00712EC3 /* xcconfig */, 848934FA1F62484F00CEBD24 /* Info.plist */, - 769F2630AF8DC873D4A73567 /* NewsBlur */, ); sourceTree = ""; usesTabs = 1; @@ -1068,7 +1120,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -1091,7 +1143,9 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 5132AAC52448BAD90077840A /* TwitterFeedProvider.swift in Sources */, 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */, + 51B3630B244B634A000DEF2A /* TwitterMention.swift in Sources */, 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, @@ -1100,16 +1154,20 @@ 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */, 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */, + 516896352448EBEA00185AC5 /* FeedProviderManager.swift in Sources */, 9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */, 9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */, 9E5EC15B23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift in Sources */, + 51B36305244B6135000DEF2A /* TwitterEntities.swift in Sources */, + 5132DE832449306F00806ADE /* TwitterStatus.swift in Sources */, 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */, 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, + 51B36309244B62A5000DEF2A /* TwitterURL.swift in Sources */, 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, @@ -1117,7 +1175,6 @@ 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, 9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, - 515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */, 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, @@ -1135,6 +1192,7 @@ 9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */, 3B826DAB2385C81C00FC1ADB /* FeedWranglerConfig.swift in Sources */, 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */, + 51B36315244BCCA4000DEF2A /* TwitterSearchResult.swift in Sources */, 9EB1D576238E6A3900A753D7 /* FeedlyAddNewFeedOperation.swift in Sources */, 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */, 9E672396236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift in Sources */, @@ -1149,21 +1207,25 @@ 9EEEF7212355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift in Sources */, 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */, 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, + 51B3630F244B6CB9000DEF2A /* TwitterExtendedEntities.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, + 5132AAC42448BAD90077840A /* FeedProvider.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */, 9E5DE60E23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift in Sources */, 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */, 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */, + 51B36307244B6234000DEF2A /* TwitterHashtag.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */, 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */, + 51B36313244B8B5E000DEF2A /* TwitterVideo.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, 9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, @@ -1180,7 +1242,6 @@ 9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */, 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */, 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, - 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, @@ -1190,7 +1251,6 @@ 51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */, 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, - 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */, @@ -1199,12 +1259,16 @@ 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, + 510E3317244E0CED00E7A6AF /* TwitterMedia.swift in Sources */, 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, + 5132DE812449159100806ADE /* TwitterUser.swift in Sources */, 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, + 51B3630D244B6428000DEF2A /* TwitterSymbol.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */, + 51B36311244B6CFB000DEF2A /* TwitterExtendedMedia.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index a7c201cab..d575434cb 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -9,6 +9,7 @@ import Foundation import Articles import RSWeb +import Secrets protocol AccountDelegate { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index f352c35cb..3b3358447 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -16,6 +16,7 @@ import RSParser import Articles import ArticlesDatabase import RSWeb +import Secrets public enum CloudKitAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." @@ -211,72 +212,20 @@ final class CloudKitAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - let editedName = name == nil || name!.isEmpty ? nil : name - - guard let url = URL(string: urlString) else { + guard let url = URL(string: urlString), let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - BatchUpdate.shared.start() - refreshProgress.addToNumberOfTasksAndRemaining(3) - 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 { - BatchUpdate.shared.end() - self.refreshProgress.clear() - completion(.failure(AccountError.createErrorNotFound)) - return - } - - if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { - BatchUpdate.shared.end() - self.refreshProgress.clear() - completion(.failure(AccountError.createErrorAlreadySubscribed)) - return - } - - self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: editedName, container: container) { result in + let editedName = name == nil || name!.isEmpty ? nil : name - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - - let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - feed.editedName = editedName - feed.externalID = externalID - container.addWebFeed(feed) - - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() - - if let parsedFeed = parsedFeed { - account.update(feed, with: parsedFeed, {_ in - BatchUpdate.shared.end() - completion(.success(feed)) - }) - } - - } - - case .failure(let error): - BatchUpdate.shared.end() - self.refreshProgress.clear() - completion(.failure(error)) - } - } - - case .failure: - BatchUpdate.shared.end() - self.refreshProgress.clear() - completion(.failure(AccountError.createErrorNotFound)) - } - + // Username should be part of the URL on new feed adds + if let feedProvider = FeedProviderManager.shared.best(for: urlComponents) { + createProviderWebFeed(for: account, urlComponents: urlComponents, editedName: editedName, container: container, feedProvider: feedProvider, completion: completion) + } else { + createRSSWebFeed(for: account, url: url, editedName: editedName, container: container, completion: completion) } - + } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { @@ -545,7 +494,7 @@ private extension CloudKitAccountDelegate { self.sendArticleStatus(for: account) { result in switch result { case .success: - self.refreshProgress.completeTask() + self.refreshProgress.clear() completion(.success(())) case .failure(let error): fail(error) @@ -593,7 +542,8 @@ private extension CloudKitAccountDelegate { self.refreshProgress.completeTask() - self.refresher.refreshFeeds(webFeeds) { + self.refreshWebFeeds(account, webFeeds) { + self.refreshProgress.clear() account.metadata.lastArticleFetchEndTime = Date() } @@ -614,6 +564,224 @@ private extension CloudKitAccountDelegate { } } + func refreshWebFeeds(_ account: Account, _ webFeeds: Set, completion: @escaping () -> Void) { + + var newArticles = Set
() + var deletedArticles = Set
() + + var refresherWebFeeds = Set() + let group = DispatchGroup() + + refreshProgress.addToNumberOfTasksAndRemaining(2) + + for webFeed in webFeeds { + if let components = URLComponents(string: webFeed.url), let feedProvider = FeedProviderManager.shared.best(for: components) { + group.enter() + feedProvider.refresh(webFeed) { result in + switch result { + case .success(let parsedItems): + + account.update(webFeed.webFeedID, with: parsedItems) { result in + switch result { + case .success(let articleChanges): + + newArticles.formUnion(articleChanges.newArticles ?? Set
()) + deletedArticles.formUnion(articleChanges.deletedArticles ?? Set
()) + + self.refreshProgress.completeTask() + group.leave() + + case .failure(let error): + os_log(.error, log: self.log, "Feed Provider refresh update error: %@.", error.localizedDescription) + self.refreshProgress.completeTask() + group.leave() + } + + } + + case .failure(let error): + os_log(.error, log: self.log, "Feed Provider refresh error: %@.", error.localizedDescription) + self.refreshProgress.completeTask() + group.leave() + } + } + } else { + refresherWebFeeds.insert(webFeed) + } + } + + group.enter() + refresher.refreshFeeds(refresherWebFeeds) { + group.leave() + } + + group.notify(queue: DispatchQueue.main) { + + self.articlesZone.deleteArticles(deletedArticles) { _ in + self.refreshProgress.completeTask() + self.articlesZone.sendNewArticles(newArticles) { _ in + self.refreshProgress.completeTask() + completion() + } + } + + } + + } + + func createProviderWebFeed(for account: Account, urlComponents: URLComponents, editedName: String?, container: Container, feedProvider: FeedProvider, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(5) + + feedProvider.assignName(urlComponents) { result in + self.refreshProgress.completeTask() + switch result { + + case .success(let name): + + guard let urlString = urlComponents.url?.absoluteString else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + self.accountZone.createWebFeed(url: urlString, editedName: editedName, container: container) { result in + + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + + let feed = account.createWebFeed(with: name, url: urlString, webFeedID: urlString, homePageURL: nil) + feed.editedName = editedName + feed.externalID = externalID + container.addWebFeed(feed) + + feedProvider.refresh(feed) { result in + self.refreshProgress.completeTask() + switch result { + case .success(let parsedItems): + + account.update(urlString, with: parsedItems) { result in + switch result { + case .success(let articleChanges): + + let newArticles = articleChanges.newArticles ?? Set
() + let deletedArticles = articleChanges.deletedArticles ?? Set
() + + self.articlesZone.deleteArticles(deletedArticles) { _ in + self.refreshProgress.completeTask() + self.articlesZone.sendNewArticles(newArticles) { _ in + self.refreshProgress.clear() + completion(.success(feed)) + } + } + + case .failure(let error): + self.refreshProgress.clear() + completion(.failure(error)) + } + + } + + case .failure: + self.refreshProgress.clear() + completion(.failure(AccountError.createErrorNotFound)) + } + } + + case .failure(let error): + self.refreshProgress.clear() + completion(.failure(error)) + } + } + + case .failure(let error): + self.refreshProgress.clear() + completion(.failure(error)) + } + } + } + + func createRSSWebFeed(for account: Account, url: URL, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + BatchUpdate.shared.start() + 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 { + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(AccountError.createErrorAlreadySubscribed)) + return + } + + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: editedName, container: container) { result in + + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + + let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + feed.editedName = editedName + feed.externalID = externalID + container.addWebFeed(feed) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() + + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed) { result in + switch result { + case .success(let articleChanges): + + BatchUpdate.shared.end() + let newArticles = articleChanges.newArticles ?? Set
() + let deletedArticles = articleChanges.deletedArticles ?? Set
() + + self.articlesZone.deleteArticles(deletedArticles) { _ in + self.refreshProgress.completeTask() + self.articlesZone.sendNewArticles(newArticles) { _ in + self.refreshProgress.clear() + completion(.success(feed)) + } + } + + case .failure(let error): + self.refreshProgress.clear() + completion(.failure(error)) + } + + } + } else { + self.refreshProgress.clear() + completion(.success(feed)) + } + + } + + case .failure(let error): + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(error)) + } + } + + case .failure: + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(AccountError.createErrorNotFound)) + } + + } + } + func processAccountError(_ account: Account, _ error: Error) { if case CloudKitZoneError.userDeletedZone = error { account.removeFeeds(account.topLevelWebFeeds) @@ -643,7 +811,6 @@ extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { } func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher) { - refreshProgress.clear() } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 8415bd5d0..deed6f597 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -72,7 +72,7 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String - + if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { updateWebFeed(webFeed, editedName: editedName, containerExternalIDs: containerExternalIDs) @@ -180,28 +180,60 @@ private extension CloudKitAcountZoneDelegate { } func createWebFeedIfNecessary(url: URL, editedName: String?, webFeedExternalID: String, container: Container, completion: @escaping (WebFeed) -> Void) { - guard let account = account else { return } + guard let account = account, let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } if let webFeed = account.existingWebFeed(withExternalID: webFeedExternalID) { completion(webFeed) return } - let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + let webFeed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) webFeed.editedName = editedName webFeed.externalID = webFeedExternalID - container.addWebFeed(webFeed) - refreshProgress?.addToNumberOfTasksAndRemaining(1) - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress?.completeTask() - if let parsedFeed = parsedFeed { - account.update(webFeed, with: parsedFeed, { _ in + if let feedProvider = FeedProviderManager.shared.best(for: urlComponents) { + + refreshProgress?.addToNumberOfTasksAndRemaining(2) + feedProvider.assignName(urlComponents) { result in + self.refreshProgress?.completeTask() + switch result { + case .success(let name): + + webFeed.name = name + container.addWebFeed(webFeed) + + feedProvider.refresh(webFeed) { result in + self.refreshProgress?.completeTask() + switch result { + case .success(let parsedItems): + account.update(url.absoluteString, with: parsedItems) { _ in + completion(webFeed) + } + case .failure: + completion(webFeed) + } + } + + case .failure: completion(webFeed) - }) - } else { - completion(webFeed) + } } + + } else { + + refreshProgress?.addToNumberOfTasksAndRemaining(1) + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress?.completeTask() + if let parsedFeed = parsedFeed { + account.update(webFeed, with: parsedFeed, { _ in + container.addWebFeed(webFeed) + completion(webFeed) + }) + } else { + completion(webFeed) + } + } + } } diff --git a/Frameworks/Account/FeedProvider/FeedProvider.swift b/Frameworks/Account/FeedProvider/FeedProvider.swift new file mode 100644 index 000000000..eecb9d915 --- /dev/null +++ b/Frameworks/Account/FeedProvider/FeedProvider.swift @@ -0,0 +1,33 @@ +// +// FeedProvider.swift +// FeedProvider +// +// Created by Maurice Parker on 4/6/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +public enum FeedProviderAbility { + case owner + case available + case none +} + +public protocol FeedProvider { + + /// Informs the caller of the ability for this feed provider to service the given URL + func ability(_ urlComponents: URLComponents) -> FeedProviderAbility + + /// Provide the iconURL of the given URL + func iconURL(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) + + /// Construct a Name for the new feed + func assignName(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) + + /// Refresh all the article entries (ParsedItems) + func refresh(_ webFeed: WebFeed, completion: @escaping (Result, Error>) -> Void) + +} diff --git a/Frameworks/Account/FeedProvider/FeedProviderManager.swift b/Frameworks/Account/FeedProvider/FeedProviderManager.swift new file mode 100644 index 000000000..4177f550c --- /dev/null +++ b/Frameworks/Account/FeedProvider/FeedProviderManager.swift @@ -0,0 +1,42 @@ +// +// FeedProviderManager.swift +// Account +// +// Created by Maurice Parker on 4/16/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public protocol FeedProviderManagerDelegate: class { + var activeFeedProviders: [FeedProvider] { get } +} + +public final class FeedProviderManager { + + public static let shared = FeedProviderManager() + public weak var delegate: FeedProviderManagerDelegate? + + public func best(for offered: URLComponents) -> FeedProvider? { + if let owner = feedProviderMatching(offered, ability: .owner) { + return owner + } + return feedProviderMatching(offered, ability: .available) + } + +} + +private extension FeedProviderManager { + + func feedProviderMatching(_ offered: URLComponents, ability: FeedProviderAbility) -> FeedProvider? { + if let delegate = delegate { + for feedProvider in delegate.activeFeedProviders { + if feedProvider.ability(offered) == ability { + return feedProvider + } + } + } + return nil + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterEntities.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterEntities.swift new file mode 100644 index 000000000..15d4c39ee --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterEntities.swift @@ -0,0 +1,70 @@ +// +// TwitterEntities.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +protocol TwitterEntity { + var indices: [Int]? { get } + func renderAsHTML() -> String +} + +extension TwitterEntity { + + var startIndex: Int { + if let indices = indices, indices.count > 0 { + return indices[0] + } + return 0 + } + + var endIndex: Int { + if let indices = indices, indices.count > 1 { + return indices[1] + } + return 0 + } + +} + +struct TwitterEntities: Codable { + + let hashtags: [TwitterHashtag]? + let urls: [TwitterURL]? + let userMentions: [TwitterMention]? + let symbols: [TwitterSymbol]? + let media: [TwitterMedia]? + + enum CodingKeys: String, CodingKey { + case hashtags = "hashtags" + case urls = "urls" + case userMentions = "user_mentions" + case symbols = "symbols" + case media = "media" + } + + func combineAndSort() -> [TwitterEntity] { + var entities = [TwitterEntity]() + if let hashtags = hashtags { + entities.append(contentsOf: hashtags) + } + if let urls = urls { + entities.append(contentsOf: urls) + } + if let userMentions = userMentions { + entities.append(contentsOf: userMentions) + } + if let symbols = symbols { + entities.append(contentsOf: symbols) + } + if let media = media { + entities.append(contentsOf: media) + } + return entities.sorted(by: { $0.startIndex < $1.startIndex }) + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedEntities.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedEntities.swift new file mode 100644 index 000000000..e449e66ca --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedEntities.swift @@ -0,0 +1,28 @@ +// +// TwitterExtendedEntities.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterExtendedEntities: Codable { + + let medias: [TwitterExtendedMedia]? + + enum CodingKeys: String, CodingKey { + case medias = "media" + } + + func renderAsHTML() -> String { + var html = String() + if let medias = medias { + for media in medias { + html += media.renderAsHTML() + } + } + return html + } +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedMedia.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedMedia.swift new file mode 100644 index 000000000..91a3bdf9e --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterExtendedMedia.swift @@ -0,0 +1,94 @@ +// +// TwitterExtendedMedia.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterExtendedMedia: Codable { + + let idStr: String? + let indices: [Int]? + let mediaURL: String? + let httpsMediaURL: String? + let url: String? + let displayURL: String? + let type: String? + let video: TwitterVideo? + + enum CodingKeys: String, CodingKey { + case idStr = "idStr" + case indices = "indices" + case mediaURL = "media_url" + case httpsMediaURL = "media_url_https" + case url = "url" + case displayURL = "display_url" + case type = "type" + case video = "video_info" + } + + func renderAsHTML() -> String { + var html = String() + + switch type { + case "photo": + html += renderPhotoAsHTML() + case "video", "animated_gif": + html += renderVideoAsHTML() + default: + break + } + + return html + } + +} + +private extension TwitterExtendedMedia { + + func renderPhotoAsHTML() -> String { + if let httpsMediaURL = httpsMediaURL { + return "
" + } + if let mediaURL = mediaURL { + return "
" + } + return "" + } + + func renderVideoAsHTML() -> String { + guard let bestVariantURL = findBestVariant()?.url else { return "" } + + var html = "" + return html + } + + func findBestVariant() -> TwitterVideo.Variant? { + var best: TwitterVideo.Variant? = nil + if let variants = video?.variants { + for variant in variants { + if let currentBest = best { + if variant.bitrate ?? 0 > currentBest.bitrate ?? 0 { + best = variant + } + } else { + best = variant + } + } + } + return best + } + +// +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterFeedProvider.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterFeedProvider.swift new file mode 100644 index 000000000..6ce028472 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterFeedProvider.swift @@ -0,0 +1,389 @@ +// +// TwitterFeedProvider.swift +// FeedProvider +// +// Created by Maurice Parker on 4/7/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import Secrets +import OAuthSwift +import RSParser + +public enum TwitterFeedProviderError: LocalizedError { + case screenNameNotFound + case unknown + + public var localizedDescription: String { + switch self { + case .screenNameNotFound: + return NSLocalizedString("Unable to determine screen name.", comment: "Screen name") + case .unknown: + return NSLocalizedString("An unknown Twitter Feed Provider error has occurred.", comment: "Screen name") + } + } +} + +public enum TwitterFeedType: Int { + case homeTimeline = 0 + case mentions = 1 + case screenName = 2 + case search = 3 +} + +public struct TwitterFeedProvider: FeedProvider { + + private static let server = "api.twitter.com" + private static let apiBase = "https://api.twitter.com/1.1/" + private static let dateFormat = "EEE MMM dd HH:mm:ss Z yyyy" + + private static let userPaths = ["/home", "/notifications"] + private static let reservedPaths = ["/search", "/explore", "/messages", "/i", "/compose"] + + public var screenName: String + + private var oauthToken: String + private var oauthTokenSecret: String + + private var client: OAuthSwiftClient + + public init?(tokenSuccess: OAuthSwift.TokenSuccess) { + guard let screenName = tokenSuccess.parameters["screen_name"] as? String else { + return nil + } + + self.screenName = screenName + self.oauthToken = tokenSuccess.credential.oauthToken + self.oauthTokenSecret = tokenSuccess.credential.oauthTokenSecret + + let tokenCredentials = Credentials(type: .oauthAccessToken, username: screenName, secret: oauthToken) + try? CredentialsManager.storeCredentials(tokenCredentials, server: Self.server) + + let tokenSecretCredentials = Credentials(type: .oauthAccessTokenSecret, username: screenName, secret: oauthTokenSecret) + try? CredentialsManager.storeCredentials(tokenSecretCredentials, server: Self.server) + + client = OAuthSwiftClient(consumerKey: Secrets.twitterConsumerKey, + consumerSecret: Secrets.twitterConsumerSecret, + oauthToken: oauthToken, + oauthTokenSecret: oauthTokenSecret, + version: .oauth1) + } + + public init?(screenName: String) { + self.screenName = screenName + + guard let tokenCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthAccessToken, server: Self.server, username: screenName), + let tokenSecretCredentials = try? CredentialsManager.retrieveCredentials(type: .oauthAccessTokenSecret, server: Self.server, username: screenName) else { + return nil + } + + self.oauthToken = tokenCredentials.secret + self.oauthTokenSecret = tokenSecretCredentials.secret + + client = OAuthSwiftClient(consumerKey: Secrets.twitterConsumerKey, + consumerSecret: Secrets.twitterConsumerSecret, + oauthToken: oauthToken, + oauthTokenSecret: oauthTokenSecret, + version: .oauth1) + } + + public func ability(_ urlComponents: URLComponents) -> FeedProviderAbility { + guard urlComponents.host?.hasSuffix("twitter.com") ?? false else { + return .none + } + + if let username = urlComponents.user { + if username == screenName { + return .owner + } else { + return .none + } + } + + return .available + } + + public func iconURL(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) { + if let screenName = deriveScreenName(urlComponents) { + retrieveUser(screenName: screenName) { result in + switch result { + case .success(let user): + if let avatarURL = user.avatarURL { + completion(.success(avatarURL)) + } else { + completion(.failure(TwitterFeedProviderError.screenNameNotFound)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(TwitterFeedProviderError.screenNameNotFound)) + } + } + + public func assignName(_ urlComponents: URLComponents, completion: @escaping (Result) -> Void) { + switch urlComponents.path { + + case "", "/", "/home": + let name = NSLocalizedString("Twitter Timeline", comment: "Twitter Timeline") + completion(.success(name)) + + case "/notifications/mentions": + let name = NSLocalizedString("Twitter Mentions", comment: "Twitter Mentions") + completion(.success(name)) + + case "/search": + if let query = urlComponents.queryItems?.first(where: { $0.name == "q" })?.value { + let localized = NSLocalizedString("Twitter Search: %@", comment: "Twitter Search") + let searchName = NSString.localizedStringWithFormat(localized as NSString, query) as String + completion(.success(searchName)) + } else { + let name = NSLocalizedString("Twitter Search", comment: "Twitter Search") + completion(.success(name)) + } + + default: + if let hashtag = deriveHashtag(urlComponents) { + completion(.success("#\(hashtag)")) + } else if let screenName = deriveScreenName(urlComponents) { + retrieveUser(screenName: screenName) { result in + switch result { + case .success(let user): + if let userName = user.name { + completion(.success(userName)) + } else { + completion(.failure(TwitterFeedProviderError.screenNameNotFound)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(TwitterFeedProviderError.unknown)) + } + + } + } + + public func refresh(_ webFeed: WebFeed, completion: @escaping (Result, Error>) -> Void) { + guard let urlComponents = URLComponents(string: webFeed.url) else { + completion(.failure(TwitterFeedProviderError.unknown)) + return + } + + let api: String + var parameters = [String: Any]() + var isSearch = false + + switch urlComponents.path { + case "", "/", "/home": + parameters["count"] = 100 + api = "statuses/home_timeline.json" + case "/notifications/mentions": + api = "statuses/mentions_timeline.json" + case "/search": + api = "search/tweets.json" + if let query = urlComponents.queryItems?.first(where: { $0.name == "q" })?.value { + parameters["q"] = query + } + isSearch = true + default: + if let hashtag = deriveHashtag(urlComponents) { + api = "search/tweets.json" + parameters["q"] = "#\(hashtag)" + isSearch = true + } else { + api = "statuses/user_timeline.json" + parameters["exclude_replies"] = true + if let screenName = deriveScreenName(urlComponents) { + parameters["screen_name"] = screenName + } else { + completion(.failure(TwitterFeedProviderError.unknown)) + return + } + } + } + + retrieveTweets(api: api, parameters: parameters, isSearch: isSearch) { result in + switch result { + case .success(let tweets): + let parsedItems = self.makeParsedItems(webFeed.url, tweets) + completion(.success(parsedItems)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + public static func buildURL(_ type: TwitterFeedType, username: String?, screenName: String?, searchField: String?) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = "twitter.com" + + switch type { + case .homeTimeline: + guard let username = username else { + return nil + } + components.user = username + case .mentions: + guard let username = username else { + return nil + } + components.user = username + components.path = "/notifications/mentions" + case .screenName: + guard let screenName = screenName else { + return nil + } + components.path = "/\(screenName)" + case .search: + guard let searchField = searchField else { + return nil + } + components.path = "/search" + components.queryItems = [URLQueryItem(name: "q", value: searchField)] + } + + return components.url + } + +} + +// MARK: OAuth1SwiftProvider + +extension TwitterFeedProvider: OAuth1SwiftProvider { + + public static var oauth1Swift: OAuth1Swift { + return OAuth1Swift( + consumerKey: Secrets.twitterConsumerKey, + consumerSecret: Secrets.twitterConsumerSecret, + requestTokenUrl: "https://api.twitter.com/oauth/request_token", + authorizeUrl: "https://api.twitter.com/oauth/authorize", + accessTokenUrl: "https://api.twitter.com/oauth/access_token" + ) + } + +} + +// MARK: Private + +private extension TwitterFeedProvider { + + func deriveHashtag(_ urlComponents: URLComponents) -> String? { + let path = urlComponents.path + if path.starts(with: "/hashtag/"), let startIndex = path.index(path.startIndex, offsetBy: 9, limitedBy: path.endIndex), startIndex < path.endIndex { + return String(path[startIndex.. String? { + let path = urlComponents.path + guard !Self.reservedPaths.contains(path) else { return nil } + + if path.isEmpty || Self.userPaths.contains(path) { + return screenName + } else { + return String(path.suffix(from: path.index(path.startIndex, offsetBy: 1))) + } + } + + func retrieveUser(screenName: String, completion: @escaping (Result) -> Void) { + let url = "\(Self.apiBase)users/show.json" + let parameters = ["screen_name": screenName] + + client.get(url, parameters: parameters) { result in + switch result { + case .success(let response): + let decoder = JSONDecoder() + do { + let user = try decoder.decode(TwitterUser.self, from: response.data) + completion(.success(user)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveTweets(api: String, parameters: [String: Any], isSearch: Bool, completion: @escaping (Result<[TwitterStatus], Error>) -> Void) { + let url = "\(Self.apiBase)\(api)" + var expandedParameters = parameters + expandedParameters["tweet_mode"] = "extended" + + client.get(url, parameters: expandedParameters) { result in + switch result { + case .success(let response): + + let decoder = JSONDecoder() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = Self.dateFormat + decoder.dateDecodingStrategy = .formatted(dateFormatter) + + do { + let tweets: [TwitterStatus] + if isSearch { + let searchResult = try decoder.decode(TwitterSearchResult.self, from: response.data) + if let statuses = searchResult.statuses { + tweets = statuses + } else { + tweets = [TwitterStatus]() + } + } else { + tweets = try decoder.decode([TwitterStatus].self, from: response.data) + } + completion(.success(tweets)) + } catch { + completion(.failure(error)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func makeParsedItems(_ webFeedURL: String, _ statuses: [TwitterStatus]) -> Set { + var parsedItems = Set() + + for status in statuses { + guard let idStr = status.idStr, let statusURL = status.url else { continue } + + let parsedItem = ParsedItem(syncServiceID: nil, + uniqueID: idStr, + feedURL: webFeedURL, + url: statusURL, + externalURL: nil, + title: nil, + language: nil, + contentHTML: status.renderAsHTML(), + contentText: status.renderAsText(), + summary: nil, + imageURL: nil, + bannerImageURL: nil, + datePublished: status.createdAt, + dateModified: nil, + authors: makeParsedAuthors(status.user), + tags: nil, + attachments: nil) + parsedItems.insert(parsedItem) + } + + return parsedItems + } + + func makeUserURL(_ screenName: String) -> String { + return "https://twitter.com/\(screenName)" + } + + func makeParsedAuthors(_ user: TwitterUser?) -> Set? { + guard let user = user else { return nil } + return Set([ParsedAuthor(name: user.name, url: user.url, avatarURL: user.avatarURL, emailAddress: nil)]) + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterHashtag.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterHashtag.swift new file mode 100644 index 000000000..029f56c43 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterHashtag.swift @@ -0,0 +1,28 @@ +// +// TwitterHashtag.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterHashtag: Codable, TwitterEntity { + + let text: String? + let indices: [Int]? + + enum CodingKeys: String, CodingKey { + case text = "text" + case indices = "indices" + } + + func renderAsHTML() -> String { + var html = String() + if let text = text { + html += "#\(text)" + } + return html + } +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterMedia.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterMedia.swift new file mode 100644 index 000000000..57f279a19 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterMedia.swift @@ -0,0 +1,22 @@ +// +// TwitterMedia.swift +// Account +// +// Created by Maurice Parker on 4/20/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterMedia: Codable, TwitterEntity { + + let indices: [Int]? + + enum CodingKeys: String, CodingKey { + case indices = "indices" + } + + func renderAsHTML() -> String { + return String() + } +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterMention.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterMention.swift new file mode 100644 index 000000000..4cd00551f --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterMention.swift @@ -0,0 +1,33 @@ +// +// TwitterMention.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterMention: Codable, TwitterEntity { + + let name: String? + let indices: [Int]? + let screenName: String? + let idStr: String? + + enum CodingKeys: String, CodingKey { + case name = "url" + case indices = "indices" + case screenName = "screen_name" + case idStr = "idStr" + } + + func renderAsHTML() -> String { + var html = String() + if let screenName = screenName { + html += "@\(screenName)" + } + return html + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterSearchResult.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterSearchResult.swift new file mode 100644 index 000000000..6a8a1373f --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterSearchResult.swift @@ -0,0 +1,19 @@ +// +// TwitterSearchResult.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterSearchResult: Codable { + + let statuses: [TwitterStatus]? + + enum CodingKeys: String, CodingKey { + case statuses = "statuses" + } +} + diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterStatus.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterStatus.swift new file mode 100644 index 000000000..59f3dfc58 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterStatus.swift @@ -0,0 +1,182 @@ +// +// TwitterStatus.swift +// Account +// +// Created by Maurice Parker on 4/16/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +final class TwitterStatus: Codable { + + let createdAt: Date? + let idStr: String? + let fullText: String? + let displayTextRange: [Int]? + let user: TwitterUser? + let truncated: Bool? + let retweeted: Bool? + let retweetedStatus: TwitterStatus? + let quotedStatus: TwitterStatus? + let entities: TwitterEntities? + let extendedEntities: TwitterExtendedEntities? + + enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case idStr = "id_str" + case fullText = "full_text" + case displayTextRange = "display_text_range" + case user = "user" + case truncated = "truncated" + case retweeted = "retweeted" + case retweetedStatus = "retweeted_status" + case quotedStatus = "quoted_status" + case entities = "entities" + case extendedEntities = "extended_entities" + } + + var url: String? { + guard let userURL = user?.url, let idStr = idStr else { return nil } + return "\(userURL)/status/\(idStr)" + } + + func renderAsText() -> String? { + let statusToRender = retweetedStatus != nil ? retweetedStatus! : self + return statusToRender.displayText + } + + func renderAsHTML(topLevel: Bool = true) -> String { + if let retweetedStatus = retweetedStatus { + return renderAsRetweetHTML(retweetedStatus, topLevel: topLevel) + } + if let quotedStatus = quotedStatus { + return renderAsQuoteHTML(quotedStatus, topLevel: topLevel) + } + return renderAsOriginalHTML(topLevel: topLevel) + } + +} + +private extension TwitterStatus { + + var displayText: String? { + if let text = fullText, let displayRange = displayTextRange, displayRange.count > 1, + let startIndex = text.index(text.startIndex, offsetBy: displayRange[0], limitedBy: text.endIndex), + let endIndex = text.index(text.startIndex, offsetBy: displayRange[1], limitedBy: text.endIndex) { + return String(text[startIndex.. 1, let entities = entities?.combineAndSort() { + + let displayStartIndex = text.index(text.startIndex, offsetBy: displayRange[0], limitedBy: text.endIndex) ?? text.startIndex + let displayEndIndex = text.index(text.startIndex, offsetBy: displayRange[1], limitedBy: text.endIndex) ?? text.endIndex + + var html = String() + var prevIndex = displayStartIndex + var emojiOffset = 0 + + for entity in entities { + + // The twitter indices are messed up by emoji with more than one scalar, we are going to adjust for that here. + let emojiEndIndex = text.index(text.startIndex, offsetBy: entity.endIndex, limitedBy: text.endIndex) ?? text.endIndex + if prevIndex < emojiEndIndex { + let emojis = String(text[prevIndex..") + } + + // We drop off any URL which is just pointing to the quoted status. It is redundant. + if let twitterURL = entity as? TwitterURL, let expandedURL = twitterURL.expandedURL, let quotedURL = quotedStatus?.url { + if expandedURL.caseInsensitiveCompare(quotedURL) != .orderedSame { + html += entity.renderAsHTML() + } + } else { + html += entity.renderAsHTML() + } + + prevIndex = entityEndIndex + + } + + if prevIndex < displayEndIndex { + html += String(text[prevIndex.. String { + var html = "
\(status.displayHTML ?? "")
" + + if !topLevel, let createdAt = status.createdAt, let url = status.url { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + html += "\(dateFormatter.string(from: createdAt))" + } + + return html + } + + func renderAsOriginalHTML(topLevel: Bool) -> String { + var html = renderAsTweetHTML(self, topLevel: topLevel) + if topLevel { + html += extendedEntities?.renderAsHTML() ?? "" + html += retweetedStatus?.extendedEntities?.renderAsHTML() ?? "" + html += quotedStatus?.extendedEntities?.renderAsHTML() ?? "" + } + return html + } + + func renderAsRetweetHTML(_ status: TwitterStatus, topLevel: Bool) -> String { + var html = "
" + if let userHTML = status.user?.renderAsHTML() { + html += userHTML + } + html += status.renderAsHTML(topLevel: false) + html += "
" + if topLevel { + html += status.extendedEntities?.renderAsHTML() ?? "" + html += status.retweetedStatus?.extendedEntities?.renderAsHTML() ?? "" + html += status.quotedStatus?.extendedEntities?.renderAsHTML() ?? "" + } + return html + } + + func renderAsQuoteHTML(_ quotedStatus: TwitterStatus, topLevel: Bool) -> String { + var html = String() + html += renderAsTweetHTML(self, topLevel: topLevel) + html += "
" + if let userHTML = quotedStatus.user?.renderAsHTML() { + html += userHTML + } + html += quotedStatus.renderAsHTML(topLevel: false) + html += "
" + if topLevel { + html += quotedStatus.extendedEntities?.renderAsHTML() ?? "" + html += quotedStatus.retweetedStatus?.extendedEntities?.renderAsHTML() ?? "" + html += quotedStatus.quotedStatus?.extendedEntities?.renderAsHTML() ?? "" + } + return html + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterSymbol.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterSymbol.swift new file mode 100644 index 000000000..525a1a595 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterSymbol.swift @@ -0,0 +1,29 @@ +// +// TwitterSymbol.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterSymbol: Codable, TwitterEntity { + + let name: String? + let indices: [Int]? + + enum CodingKeys: String, CodingKey { + case name = "name" + case indices = "indices" + } + + func renderAsHTML() -> String { + var html = String() + if let name = name { + html += "$\(name)" + } + return html + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterURL.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterURL.swift new file mode 100644 index 000000000..d7f80c6b6 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterURL.swift @@ -0,0 +1,33 @@ +// +// TwitterURL.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterURL: Codable, TwitterEntity { + + let url: String? + let indices: [Int]? + let displayURL: String? + let expandedURL: String? + + enum CodingKeys: String, CodingKey { + case url = "url" + case indices = "indices" + case displayURL = "display_url" + case expandedURL = "expanded_url" + } + + func renderAsHTML() -> String { + var html = String() + if let expandedURL = expandedURL, let displayURL = displayURL { + html += "\(displayURL)" + } + return html + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterUser.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterUser.swift new file mode 100644 index 000000000..58806431c --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterUser.swift @@ -0,0 +1,44 @@ +// +// TwitterUser.swift +// Account +// +// Created by Maurice Parker on 4/16/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct TwitterUser: Codable { + + let name: String? + let screenName: String? + let avatarURL: String? + + enum CodingKeys: String, CodingKey { + case name = "name" + case screenName = "screen_name" + case avatarURL = "profile_image_url_https" + } + + var url: String { + return "https://twitter.com/\(screenName ?? "")" + } + + func renderAsHTML() -> String? { + var html = String() + html += "" + return html + } + +} diff --git a/Frameworks/Account/FeedProvider/Twitter/TwitterVideo.swift b/Frameworks/Account/FeedProvider/Twitter/TwitterVideo.swift new file mode 100644 index 000000000..a6df96292 --- /dev/null +++ b/Frameworks/Account/FeedProvider/Twitter/TwitterVideo.swift @@ -0,0 +1,34 @@ +// +// TwitterVideoInfo.swift +// Account +// +// Created by Maurice Parker on 4/18/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + + +struct TwitterVideo: Codable { + + let variants: [Variant]? + + enum CodingKeys: String, CodingKey { + case variants = "variants" + } + + struct Variant: Codable { + + let bitrate: Int? + let contentType: String? + let url: String? + + enum CodingKeys: String, CodingKey { + case bitrate = "bitrate" + case contentType = "content_type" + case url = "url" + } + + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 39e1107e6..17c1ae089 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -11,6 +11,7 @@ import Foundation import Foundation import SyncDatabase import RSWeb +import Secrets enum FeedWranglerError : Error { case general(message: String) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 95910f410..dffff49af 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -12,6 +12,7 @@ import RSParser import RSWeb import SyncDatabase import os.log +import Secrets final class FeedWranglerAccountDelegate: AccountDelegate { diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift index 2de3b62e9..508dfedc4 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift @@ -7,6 +7,7 @@ // import Foundation +import Secrets enum FeedWranglerConfig { static let pageSize = 100 diff --git a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift index 27e16e0d7..f5bd56477 100644 --- a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift +++ b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift @@ -12,6 +12,7 @@ import Foundation import RSWeb +import Secrets enum CreateSubscriptionResult { case created(FeedbinSubscription) diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index 1e03828dc..8159c4838 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -13,6 +13,7 @@ import RSParser import RSWeb import SyncDatabase import os.log +import Secrets public enum FeedbinAccountDelegateError: String, Error { case invalidParameter = "There was an invalid parameter passed." diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index d52c83bb8..f26c6d8e8 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets final class FeedlyAPICaller { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift index f9165f06e..9e29bbbe2 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate+OAuth.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets /// Models the access token response from Feedly. /// https://developer.feedly.com/v3/auth/#exchanging-an-auth-code-for-a-refresh-token-and-an-access-token diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index d8606781a..61dba4f39 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -12,6 +12,7 @@ import RSParser import RSWeb import SyncDatabase import os.log +import Secrets final class FeedlyAccountDelegate: AccountDelegate { diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift b/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift index 420aa5ed5..56b28361a 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift @@ -7,6 +7,7 @@ // import Foundation +import Secrets extension OAuthAuthorizationClient { diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift index 836989e78..6415f90b2 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets /// Client-specific information for requesting an authorization code grant. /// Accounts are responsible for the scope. diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift index 76fdde08b..07dc4c8ca 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift @@ -10,6 +10,7 @@ import Foundation import os.log import RSWeb import RSCore +import Secrets class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index d4852c2f4..200554ed3 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -11,6 +11,7 @@ import os.log import SyncDatabase import RSWeb import RSCore +import Secrets class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift index 43ce44643..09968d750 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import Secrets /// Single responsibility is to identify articles that have changed since a particular date. /// diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift index 5c9045a98..8c0ba5b03 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift @@ -9,6 +9,7 @@ import Foundation import os.log import SyncDatabase +import Secrets /// Clone locally the remote starred article state. /// diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift index 8e7716159..02dc9af61 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import Secrets /// Ensure a status exists for every article id the user might be interested in. /// diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift index 286598af8..a06ebe0f5 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift @@ -10,6 +10,7 @@ import Foundation import os.log import RSParser import SyncDatabase +import Secrets /// Clone locally the remote unread article state. /// diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift index b4878dc49..096ec5c5e 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift @@ -9,6 +9,7 @@ import Foundation import os.log import RSWeb +import Secrets final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift index 6a2e36a76..6cbde0443 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift @@ -11,6 +11,7 @@ import os.log import SyncDatabase import RSWeb import RSCore +import Secrets /// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past. final class FeedlySyncAllOperation: FeedlyOperation { diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift index 7e98eea67..47625992d 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift @@ -11,6 +11,7 @@ import os.log import RSParser import RSCore import RSWeb +import Secrets final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index bbe13374f..8be696fd0 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -7,11 +7,13 @@ // import Foundation +import os.log import RSCore import RSParser import Articles import ArticlesDatabase import RSWeb +import Secrets public enum LocalAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." @@ -19,6 +21,8 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate { + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "LocalAccount") + weak var account: Account? private lazy var refresher: LocalAccountRefresher? = { @@ -35,23 +39,55 @@ final class LocalAccountDelegate: AccountDelegate { var accountMetadata: AccountMetadata? let refreshProgress = DownloadProgress(numberOfTasks: 0) - var refreshAllCompletion: ((Result) -> Void)? = nil func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { completion() } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - guard refreshAllCompletion == nil else { + guard refreshProgress.isComplete else { completion(.success(())) return } - - refreshAllCompletion = completion - + + var refresherWebFeeds = Set() let webFeeds = account.flattenedWebFeeds() - refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count) - refresher?.refreshFeeds(webFeeds) + + let group = DispatchGroup() + + for webFeed in webFeeds { + if let components = URLComponents(string: webFeed.url), let feedProvider = FeedProviderManager.shared.best(for: components) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + group.enter() + feedProvider.refresh(webFeed) { result in + switch result { + case .success(let parsedItems): + account.update(webFeed.webFeedID, with: parsedItems) { _ in + self.refreshProgress.completeTask() + group.leave() + } + case .failure(let error): + os_log(.error, log: self.log, "Feed Provider refresh error: %@.", error.localizedDescription) + self.refreshProgress.completeTask() + group.leave() + } + } + } else { + refresherWebFeeds.insert(webFeed) + } + } + + refreshProgress.addToNumberOfTasksAndRemaining(refresherWebFeeds.count) + group.enter() + refresher?.refreshFeeds(refresherWebFeeds) { + group.leave() + } + + group.notify(queue: DispatchQueue.main) { + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + } func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { @@ -105,52 +141,17 @@ final class LocalAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - guard let url = URL(string: urlString) else { + guard let url = URL(string: urlString), let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - refreshProgress.addToNumberOfTasksAndRemaining(1) - 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 { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - return - } - - if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorAlreadySubscribed)) - return - } - - let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() - - if let parsedFeed = parsedFeed { - account.update(feed, with: parsedFeed, {_ in}) - } - - feed.editedName = name - - container.addWebFeed(feed) - completion(.success(feed)) - - } - - case .failure: - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - } - + // Username should be part of the URL on new feed adds + if let feedProvider = FeedProviderManager.shared.best(for: urlComponents) { + createProviderWebFeed(for: account, urlComponents: urlComponents, editedName: name, container: container, feedProvider: feedProvider, completion: completion) + } else { + createRSSWebFeed(for: account, url: url, editedName: name, container: container, completion: completion) } - } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { @@ -244,9 +245,101 @@ extension LocalAccountDelegate: LocalAccountRefresherDelegate { func localAccountRefresherDidFinish(_ refresher: LocalAccountRefresher) { self.refreshProgress.clear() - account?.metadata.lastArticleFetchEndTime = Date() - refreshAllCompletion?(.success(())) - refreshAllCompletion = nil + } + +} + +private extension LocalAccountDelegate { + + func createProviderWebFeed(for account: Account, urlComponents: URLComponents, editedName: String?, container: Container, feedProvider: FeedProvider, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(2) + + feedProvider.assignName(urlComponents) { result in + self.refreshProgress.completeTask() + switch result { + + case .success(let name): + + guard let urlString = urlComponents.url?.absoluteString else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + let feed = account.createWebFeed(with: name, url: urlString, webFeedID: urlString, homePageURL: nil) + feed.editedName = editedName + container.addWebFeed(feed) + + feedProvider.refresh(feed) { result in + self.refreshProgress.completeTask() + switch result { + case .success(let parsedItems): + account.update(urlString, with: parsedItems) { _ in + completion(.success(feed)) + } + case .failure: + completion(.failure(AccountError.createErrorNotFound)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func createRSSWebFeed(for account: Account, url: URL, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + + // We need to use a batch update here because we need to assign add the feed to the + // container before the name has been downloaded. This will put it in the sidebar + // with an Untitled name if we don't delay it being added to the sidebar. + BatchUpdate.shared.start() + refreshProgress.addToNumberOfTasksAndRemaining(1) + 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 { + self.refreshProgress.completeTask() + BatchUpdate.shared.end() + completion(.failure(AccountError.createErrorNotFound)) + return + } + + if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { + self.refreshProgress.completeTask() + BatchUpdate.shared.end() + completion(.failure(AccountError.createErrorAlreadySubscribed)) + return + } + + let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + feed.editedName = editedName + container.addWebFeed(feed) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() + + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed, {_ in + BatchUpdate.shared.end() + completion(.success(feed)) + }) + } else { + BatchUpdate.shared.end() + completion(.failure(AccountError.createErrorNotFound)) + } + + } + + case .failure: + BatchUpdate.shared.end() + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorNotFound)) + } + + } + } } diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 5695b809a..1d87496ba 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -30,6 +30,10 @@ final class LocalAccountRefresher { }() public func refreshFeeds(_ feeds: Set, completion: (() -> Void)? = nil) { + guard !feeds.isEmpty else { + completion?() + return + } if let completion = completion { completions.append(completion) } @@ -100,8 +104,8 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } account.update(feed, with: parsedFeed) { result in - if case .success(let newAndUpdatedArticles) = result { - self.delegate?.localAccountRefresher(self, didProcess: newAndUpdatedArticles) { + if case .success(let articleChanges) = result { + self.delegate?.localAccountRefresher(self, didProcess: articleChanges) { if let httpResponse = response as? HTTPURLResponse { feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 0437bb386..02935e1d9 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets final class NewsBlurAPICaller: NSObject { static let SessionIdCookie = "newsblur_sessionid" diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 609dd3085..b95ae7dab 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -13,6 +13,7 @@ import RSParser import RSWeb import SyncDatabase import os.log +import Secrets final class NewsBlurAccountDelegate: AccountDelegate { diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 722df7842..f36c69ac8 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -12,6 +12,7 @@ import RSParser import RSWeb import SyncDatabase import os.log +import Secrets public enum ReaderAPIAccountDelegateError: String, Error { case invalidParameter = "There was an invalid parameter passed." diff --git a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift index 8b61a37b9..a7622db14 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets enum CreateReaderAPISubscriptionResult { case created(ReaderAPISubscription) diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/URLRequest+RSWeb.swift similarity index 94% rename from Frameworks/Account/Credentials/URLRequest+RSWeb.swift rename to Frameworks/Account/URLRequest+RSWeb.swift index f82f3de02..2f47785a8 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/URLRequest+RSWeb.swift @@ -8,6 +8,7 @@ import Foundation import RSWeb +import Secrets public extension URLRequest { @@ -60,6 +61,9 @@ public extension URLRequest { case .oauthAccessToken: let auth = "OAuth \(credentials.secret)" setValue(auth, forHTTPHeaderField: "Authorization") + case .oauthAccessTokenSecret: + assertionFailure("Token secrets are used by OAuth1. Did you mean to use `OAuthSwift` instead of a URLRequest?") + break case .oauthRefreshToken: // While both access and refresh tokens are credentials, it seems the `Credentials` cases // enumerates how the identity of the user can be proved rather than diff --git a/Frameworks/Articles/Articles.xcodeproj/project.pbxproj b/Frameworks/Articles/Articles.xcodeproj/project.pbxproj index e1c634abe..27a1ea842 100644 --- a/Frameworks/Articles/Articles.xcodeproj/project.pbxproj +++ b/Frameworks/Articles/Articles.xcodeproj/project.pbxproj @@ -311,7 +311,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj index 055dd02a4..a1349177f 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj @@ -364,14 +364,14 @@ TargetAttributes = { 844BEE361F0AB3AA004AB7CD = { CreatedOnToolsVersion = 8.3.2; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; LastSwiftMigration = 0830; - ProvisioningStyle = Manual; + ProvisioningStyle = Automatic; }; 844BEE3F1F0AB3AB004AB7CD = { CreatedOnToolsVersion = 8.3.2; - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Manual; + DevelopmentTeam = SHJK2V3AJG; + ProvisioningStyle = Automatic; }; }; }; @@ -519,7 +519,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Secrets/Credentials.swift similarity index 94% rename from Frameworks/Account/Credentials/Credentials.swift rename to Frameworks/Secrets/Credentials.swift index ef9884f5b..512012b1c 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Secrets/Credentials.swift @@ -22,6 +22,7 @@ public enum CredentialsType: String { case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" + case oauthAccessTokenSecret = "oauthAccessTokenSecret" case oauthRefreshToken = "oauthRefreshToken" } diff --git a/Frameworks/Account/Credentials/CredentialsManager.swift b/Frameworks/Secrets/CredentialsManager.swift similarity index 100% rename from Frameworks/Account/Credentials/CredentialsManager.swift rename to Frameworks/Secrets/CredentialsManager.swift diff --git a/Frameworks/Secrets/Info.plist b/Frameworks/Secrets/Info.plist new file mode 100644 index 000000000..b8d61ba53 --- /dev/null +++ b/Frameworks/Secrets/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHumanReadableCopyright + Copyright © 2020 Ranchero Software, LLC. All rights reserved. + + diff --git a/Frameworks/Secrets/OAuth1SwiftProvider.swift b/Frameworks/Secrets/OAuth1SwiftProvider.swift new file mode 100644 index 000000000..327aa34d7 --- /dev/null +++ b/Frameworks/Secrets/OAuth1SwiftProvider.swift @@ -0,0 +1,16 @@ +// +// OAuth1SwiftProvider.swift +// Secrets +// +// Created by Maurice Parker on 4/14/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import OAuthSwift + +public protocol OAuth1SwiftProvider { + + static var oauth1Swift: OAuth1Swift { get } + +} diff --git a/Shared/Secrets.swift.gyb b/Frameworks/Secrets/Secrets.swift.gyb similarity index 92% rename from Shared/Secrets.swift.gyb rename to Frameworks/Secrets/Secrets.swift.gyb index a4870a595..1f92b37c7 100644 --- a/Shared/Secrets.swift.gyb +++ b/Frameworks/Secrets/Secrets.swift.gyb @@ -2,7 +2,7 @@ %{ import os -secrets = ['FEED_WRANGLER_KEY', 'MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET'] +secrets = ['FEED_WRANGLER_KEY', 'MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET', 'TWITTER_CONSUMER_KEY', 'TWITTER_CONSUMER_SECRET'] def chunks(seq, size): return (seq[i:(i + size)] for i in range(0, len(seq), size)) diff --git a/Frameworks/Secrets/Secrets.xcodeproj/project.pbxproj b/Frameworks/Secrets/Secrets.xcodeproj/project.pbxproj new file mode 100644 index 000000000..38dc95e54 --- /dev/null +++ b/Frameworks/Secrets/Secrets.xcodeproj/project.pbxproj @@ -0,0 +1,277 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 514446BE243FFF0300EE752D /* Secrets_project_release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 514446B9243FFF0200EE752D /* Secrets_project_release.xcconfig */; }; + 514446BF243FFF0300EE752D /* Secrets_project_test.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 514446BA243FFF0200EE752D /* Secrets_project_test.xcconfig */; }; + 514446C0243FFF0300EE752D /* Secrets_project_debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 514446BB243FFF0200EE752D /* Secrets_project_debug.xcconfig */; }; + 514446C1243FFF0300EE752D /* Secrets_project.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 514446BC243FFF0200EE752D /* Secrets_project.xcconfig */; }; + 514446C2243FFF0300EE752D /* Secrets_target.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */; }; + 514446ED2440030900EE752D /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514446EC2440030900EE752D /* Secrets.swift */; }; + 514BB43B243FFBFF0023B621 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BB439243FFBFF0023B621 /* CredentialsManager.swift */; }; + 514BB43C243FFBFF0023B621 /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BB43A243FFBFF0023B621 /* Credentials.swift */; }; + 5152BEF2244633FA00138380 /* OAuth1SwiftProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152BEF1244633FA00138380 /* OAuth1SwiftProvider.swift */; }; + 51C99ABD2447DD730027D5F6 /* OAuthSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C99ABC2447DD730027D5F6 /* OAuthSwift.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 514446B9243FFF0200EE752D /* Secrets_project_release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets_project_release.xcconfig; sourceTree = ""; }; + 514446BA243FFF0200EE752D /* Secrets_project_test.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets_project_test.xcconfig; sourceTree = ""; }; + 514446BB243FFF0200EE752D /* Secrets_project_debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets_project_debug.xcconfig; sourceTree = ""; }; + 514446BC243FFF0200EE752D /* Secrets_project.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets_project.xcconfig; sourceTree = ""; }; + 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Secrets_target.xcconfig; sourceTree = ""; }; + 514446EC2440030900EE752D /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; + 514BB41A243FFA640023B621 /* Secrets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Secrets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 514BB41E243FFA640023B621 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 514BB439243FFBFF0023B621 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; + 514BB43A243FFBFF0023B621 /* Credentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; + 5152BEF1244633FA00138380 /* OAuth1SwiftProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth1SwiftProvider.swift; sourceTree = ""; }; + 51C99ABC2447DD730027D5F6 /* OAuthSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OAuthSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 514BB417243FFA640023B621 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 51C99ABD2447DD730027D5F6 /* OAuthSwift.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 514BB410243FFA640023B621 = { + isa = PBXGroup; + children = ( + 514BB43A243FFBFF0023B621 /* Credentials.swift */, + 514BB439243FFBFF0023B621 /* CredentialsManager.swift */, + 5152BEF1244633FA00138380 /* OAuth1SwiftProvider.swift */, + 514BB41E243FFA640023B621 /* Info.plist */, + 514BB41B243FFA640023B621 /* Products */, + 514446EC2440030900EE752D /* Secrets.swift */, + 514BB42B243FFAF50023B621 /* xcconfig */, + 51C99ABB2447DD730027D5F6 /* Frameworks */, + ); + sourceTree = ""; + }; + 514BB41B243FFA640023B621 /* Products */ = { + isa = PBXGroup; + children = ( + 514BB41A243FFA640023B621 /* Secrets.framework */, + ); + name = Products; + sourceTree = ""; + }; + 514BB42B243FFAF50023B621 /* xcconfig */ = { + isa = PBXGroup; + children = ( + 514446BB243FFF0200EE752D /* Secrets_project_debug.xcconfig */, + 514446B9243FFF0200EE752D /* Secrets_project_release.xcconfig */, + 514446BA243FFF0200EE752D /* Secrets_project_test.xcconfig */, + 514446BC243FFF0200EE752D /* Secrets_project.xcconfig */, + 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */, + ); + path = xcconfig; + sourceTree = ""; + }; + 51C99ABB2447DD730027D5F6 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 51C99ABC2447DD730027D5F6 /* OAuthSwift.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 514BB415243FFA640023B621 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 514BB419243FFA640023B621 /* Secrets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 514BB422243FFA640023B621 /* Build configuration list for PBXNativeTarget "Secrets" */; + buildPhases = ( + 514BB415243FFA640023B621 /* Headers */, + 514BB416243FFA640023B621 /* Sources */, + 514BB417243FFA640023B621 /* Frameworks */, + 514BB418243FFA640023B621 /* Resources */, + 514BB438243FFBB30023B621 /* Run Script: Verfiy No Build Settings */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Secrets; + productName = Credentials; + productReference = 514BB41A243FFA640023B621 /* Secrets.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 514BB411243FFA640023B621 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1140; + ORGANIZATIONNAME = "Ranchero Software, LLC"; + TargetAttributes = { + 514BB419243FFA640023B621 = { + CreatedOnToolsVersion = 11.4; + }; + }; + }; + buildConfigurationList = 514BB414243FFA640023B621 /* Build configuration list for PBXProject "Secrets" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 514BB410243FFA640023B621; + productRefGroup = 514BB41B243FFA640023B621 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 514BB419243FFA640023B621 /* Secrets */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 514BB418243FFA640023B621 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 514446C0243FFF0300EE752D /* Secrets_project_debug.xcconfig in Resources */, + 514446BF243FFF0300EE752D /* Secrets_project_test.xcconfig in Resources */, + 514446C2243FFF0300EE752D /* Secrets_target.xcconfig in Resources */, + 514446C1243FFF0300EE752D /* Secrets_project.xcconfig in Resources */, + 514446BE243FFF0300EE752D /* Secrets_project_release.xcconfig in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 514BB438243FFBB30023B621 /* Run Script: Verfiy No Build Settings */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Verfiy No Build Settings"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 514BB416243FFA640023B621 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 514BB43C243FFBFF0023B621 /* Credentials.swift in Sources */, + 514446ED2440030900EE752D /* Secrets.swift in Sources */, + 5152BEF2244633FA00138380 /* OAuth1SwiftProvider.swift in Sources */, + 514BB43B243FFBFF0023B621 /* CredentialsManager.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 514BB420243FFA640023B621 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446BB243FFF0200EE752D /* Secrets_project_debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + 514BB421243FFA640023B621 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446B9243FFF0200EE752D /* Secrets_project_release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 514BB423243FFA640023B621 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */; + buildSettings = { + PRODUCT_NAME = Secrets; + }; + name = Debug; + }; + 514BB424243FFA640023B621 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */; + buildSettings = { + PRODUCT_NAME = Secrets; + }; + name = Release; + }; + 514BB436243FFB800023B621 /* Test */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446BA243FFF0200EE752D /* Secrets_project_test.xcconfig */; + buildSettings = { + }; + name = Test; + }; + 514BB437243FFB800023B621 /* Test */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 514446BD243FFF0300EE752D /* Secrets_target.xcconfig */; + buildSettings = { + PRODUCT_NAME = Secrets; + }; + name = Test; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 514BB414243FFA640023B621 /* Build configuration list for PBXProject "Secrets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 514BB420243FFA640023B621 /* Debug */, + 514BB436243FFB800023B621 /* Test */, + 514BB421243FFA640023B621 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 514BB422243FFA640023B621 /* Build configuration list for PBXNativeTarget "Secrets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 514BB423243FFA640023B621 /* Debug */, + 514BB437243FFB800023B621 /* Test */, + 514BB424243FFA640023B621 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 514BB411243FFA640023B621 /* Project object */; +} diff --git a/Frameworks/Secrets/xcconfig/Secrets_project.xcconfig b/Frameworks/Secrets/xcconfig/Secrets_project.xcconfig new file mode 100644 index 000000000..573284972 --- /dev/null +++ b/Frameworks/Secrets/xcconfig/Secrets_project.xcconfig @@ -0,0 +1,61 @@ +CODE_SIGN_IDENTITY = Developer ID Application +DEVELOPMENT_TEAM = M8L2WTLA8W +CODE_SIGN_STYLE = Manual +PROVISIONING_PROFILE_SPECIFIER = + +// See the notes in NetNewsWire_target.xcconfig on why the +// DeveloperSettings.xcconfig is #included here + +#include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig" + +SDKROOT = macosx +MACOSX_DEPLOYMENT_TARGET = 10.14 +IPHONEOS_DEPLOYMENT_TARGET = 13.0 +SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator + +CLANG_ENABLE_OBJC_WEAK = YES +SWIFT_VERSION = 5.1 +COMBINE_HIDPI_IMAGES = YES + +COPY_PHASE_STRIP = NO +ALWAYS_SEARCH_USER_PATHS = NO +CURRENT_PROJECT_VERSION = 1 +VERSION_INFO_PREFIX = +VERSIONING_SYSTEM = apple-generic +GCC_NO_COMMON_BLOCKS = YES +GCC_C_LANGUAGE_STANDARD = gnu99 +CLANG_CXX_LANGUAGE_STANDARD = gnu++0x +CLANG_CXX_LIBRARY = libc++ +CLANG_ENABLE_MODULES = YES +CLANG_ENABLE_OBJC_ARC = YES +ENABLE_STRICT_OBJC_MSGSEND = YES +CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES +CLANG_WARN_DOCUMENTATION_COMMENTS = YES +CLANG_WARN_EMPTY_BODY = YES +CLANG_WARN_BOOL_CONVERSION = YES +CLANG_WARN_CONSTANT_CONVERSION = YES +GCC_WARN_64_TO_32_BIT_CONVERSION = YES +CLANG_WARN_ENUM_CONVERSION = YES +CLANG_WARN_INT_CONVERSION = YES +CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES +CLANG_WARN_INFINITE_RECURSION = YES +GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE +CLANG_WARN_UNREACHABLE_CODE = YES +GCC_WARN_UNUSED_FUNCTION = YES +GCC_WARN_UNUSED_VARIABLE = YES +CLANG_WARN_RANGE_LOOP_ANALYSIS = YES +CLANG_WARN_SUSPICIOUS_MOVE = YES +CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_OBJC_LITERAL_CONVERSION = YES +CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +CLANG_ANALYZER_NONNULL = YES +CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE +SWIFT_SWIFT3_OBJC_INFERENCE = Off diff --git a/Frameworks/Secrets/xcconfig/Secrets_project_debug.xcconfig b/Frameworks/Secrets/xcconfig/Secrets_project_debug.xcconfig new file mode 100644 index 000000000..94c3e5164 --- /dev/null +++ b/Frameworks/Secrets/xcconfig/Secrets_project_debug.xcconfig @@ -0,0 +1,15 @@ +#include "./Secrets_project.xcconfig" + +DEBUG_INFORMATION_FORMAT = dwarf +ENABLE_TESTABILITY = YES +GCC_DYNAMIC_NO_PIC = NO +GCC_OPTIMIZATION_LEVEL = 0 +GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited) + +MTL_ENABLE_DEBUG_INFO = YES +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG +SWIFT_COMPILATION_MODE = singlefile +SWIFT_OPTIMIZATION_LEVEL = -Onone +ONLY_ACTIVE_ARCH = YES + + diff --git a/Frameworks/Secrets/xcconfig/Secrets_project_release.xcconfig b/Frameworks/Secrets/xcconfig/Secrets_project_release.xcconfig new file mode 100644 index 000000000..7643e4a2b --- /dev/null +++ b/Frameworks/Secrets/xcconfig/Secrets_project_release.xcconfig @@ -0,0 +1,9 @@ +#include "./Secrets_project.xcconfig" + +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +ENABLE_NS_ASSERTIONS = NO + +MTL_ENABLE_DEBUG_INFO = NO +SWIFT_OPTIMIZATION_LEVEL = -O + +SWIFT_COMPILATION_MODE = wholemodule diff --git a/Frameworks/Secrets/xcconfig/Secrets_project_test.xcconfig b/Frameworks/Secrets/xcconfig/Secrets_project_test.xcconfig new file mode 100644 index 000000000..77fe155da --- /dev/null +++ b/Frameworks/Secrets/xcconfig/Secrets_project_test.xcconfig @@ -0,0 +1,3 @@ +#include "./Secrets_project_debug.xcconfig" + +OTHER_SWIFT_FLAGS = -DTEST $(inherited) diff --git a/Frameworks/Secrets/xcconfig/Secrets_target.xcconfig b/Frameworks/Secrets/xcconfig/Secrets_target.xcconfig new file mode 100644 index 000000000..fcb096eab --- /dev/null +++ b/Frameworks/Secrets/xcconfig/Secrets_target.xcconfig @@ -0,0 +1,13 @@ +INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks +SKIP_INSTALL = YES +DYLIB_COMPATIBILITY_VERSION = 1 +DYLIB_CURRENT_VERSION = 1 +DYLIB_INSTALL_NAME_BASE = @rpath +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @loader_path/Frameworks +DEFINES_MODULE = YES +FRAMEWORK_VERSION = A +INFOPLIST_FILE = Info.plist +PRODUCT_BUNDLE_IDENTIFIER = com.ranchero.Secrets +PRODUCT_NAME = $(TARGET_NAME) +CLANG_ENABLE_MODULES = YES +APPLICATION_EXTENSION_API_ONLY = YES diff --git a/Frameworks/SyncDatabase/SyncDatabase.xcodeproj/project.pbxproj b/Frameworks/SyncDatabase/SyncDatabase.xcodeproj/project.pbxproj index c6786f2e8..635e293f3 100644 --- a/Frameworks/SyncDatabase/SyncDatabase.xcodeproj/project.pbxproj +++ b/Frameworks/SyncDatabase/SyncDatabase.xcodeproj/project.pbxproj @@ -185,7 +185,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index 777438f2c..b7dca8f1f 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -77,6 +77,22 @@ struct AppAssets { return RSImage(named: "articleExtractorProgress4") }() + static var extensionPointMarsEdit: RSImage = { + return RSImage(named: "extensionPointMarsEdit")! + }() + + static var extensionPointMicroblog: RSImage = { + return RSImage(named: "extensionPointMicroblog")! + }() + + static var extensionPointTwitter: RSImage = { + return RSImage(named: "extensionPointTwitter")! + }() + + static var extensionPreference: RSImage? = { + return RSImage(contentsOfFile: "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/KEXT.icns") + }() + static var faviconTemplateImage: RSImage = { return RSImage(named: "faviconTemplateImage")! }() diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 89d42b928..f7e362c68 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -19,6 +19,7 @@ struct AppDefaults { struct Key { static let firstRunDate = "firstRunDate" static let windowState = "windowState" + static let activeExtensionPointIDs = "activeExtensionPointIDs" static let lastImageCacheFlushDate = "lastImageCacheFlushDate" static let sidebarFontSize = "sidebarFontSize" static let timelineFontSize = "timelineFontSize" @@ -72,6 +73,15 @@ struct AppDefaults { } } + static var activeExtensionPointIDs: [[AnyHashable : AnyHashable]]? { + get { + return UserDefaults.standard.object(forKey: Key.activeExtensionPointIDs) as? [[AnyHashable : AnyHashable]] + } + set { + UserDefaults.standard.set(newValue, forKey: Key.activeExtensionPointIDs) + } + } + static var lastImageCacheFlushDate: Date? { get { return date(for: Key.lastImageCacheFlushDate) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 09e6682ab..80f68a1b9 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -104,7 +104,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, super.init() AccountManager.shared = AccountManager(accountsFolder: Platform.dataSubfolder(forApplication: nil, folderName: "Accounts")!) - + FeedProviderManager.shared.delegate = ExtensionPointManager.shared + NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(inspectableObjectsDidChange(_:)), name: .InspectableObjectsDidChange, object: nil) NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(didWakeNotification(_:)), name: NSWorkspace.didWakeNotification, object: nil) @@ -134,10 +135,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, addFolderWindowController!.runSheetOnWindow(window) } - func showAddFeedSheetOnWindow(_ window: NSWindow, urlString: String?, name: String?, account: Account?, folder: Folder?) { - + func showAddWebFeedSheetOnWindow(_ window: NSWindow, urlString: String?, name: String?, account: Account?, folder: Folder?) { addFeedController = AddFeedController(hostWindow: window) - addFeedController?.showAddFeedSheet(urlString, name, account, folder) + addFeedController?.showAddFeedSheet(.webFeed, urlString, name, account, folder) } // MARK: - NSApplicationDelegate @@ -400,9 +400,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, if item.action == #selector(sortByNewestArticleOnTop(_:)) || item.action == #selector(sortByOldestArticleOnTop(_:)) { return mainWindowController?.isOpen ?? false } - if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) { + if item.action == #selector(showAddWebFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) { return !isDisplayingSheet && !AccountManager.shared.activeAccounts.isEmpty } + if item.action == #selector(showAddTwitterFeedWindow(_:)) { + return ExtensionPointManager.shared.activeExtensionPoints.values.contains(where: { $0 is TwitterFeedProvider }) + } #if !MAC_APP_STORE if item.action == #selector(toggleWebInspectorEnabled(_:)) { (item as! NSMenuItem).state = AppDefaults.webInspectorEnabled ? .on : .off @@ -423,14 +426,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } // MARK: Add Feed - func addFeed(_ urlString: String?, name: String? = nil, account: Account? = nil, folder: Folder? = nil) { + func addWebFeed(_ urlString: String?, name: String? = nil, account: Account? = nil, folder: Folder? = nil) { createAndShowMainWindowIfNecessary() if mainWindowController!.isDisplayingSheet { return } - showAddFeedSheetOnWindow(mainWindowController!.window!, urlString: urlString, name: name, account: account, folder: folder) + showAddWebFeedSheetOnWindow(mainWindowController!.window!, urlString: urlString, name: name, account: account, folder: folder) } // MARK: - Dock Badge @@ -461,8 +464,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present) } - @IBAction func showAddFeedWindow(_ sender: Any?) { - addFeed(nil) + @IBAction func showAddWebFeedWindow(_ sender: Any?) { + addWebFeed(nil) + } + + @IBAction func showAddTwitterFeedWindow(_ sender: Any?) { + createAndShowMainWindowIfNecessary() + addFeedController = AddFeedController(hostWindow: mainWindowController!.window!) + addFeedController?.showAddFeedSheet(.twitterFeed) } @IBAction func showAddFolderWindow(_ sender: Any?) { @@ -535,7 +544,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, if AccountManager.shared.anyAccountHasFeedWithURL(appNewsURLString) { return } - addFeed(appNewsURLString, name: "NetNewsWire News") + addWebFeed(appNewsURLString, name: "NetNewsWire News") } @IBAction func openWebsite(_ sender: Any?) { diff --git a/Mac/Base.lproj/AddTwitterFeedSheet.xib b/Mac/Base.lproj/AddTwitterFeedSheet.xib new file mode 100644 index 000000000..9e04ecb71 --- /dev/null +++ b/Mac/Base.lproj/AddTwitterFeedSheet.xib @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Base.lproj/AddFeedSheet.xib b/Mac/Base.lproj/AddWebFeedSheet.xib similarity index 94% rename from Mac/Base.lproj/AddFeedSheet.xib rename to Mac/Base.lproj/AddWebFeedSheet.xib index 4806b7f5b..812519436 100644 --- a/Mac/Base.lproj/AddFeedSheet.xib +++ b/Mac/Base.lproj/AddWebFeedSheet.xib @@ -1,12 +1,11 @@ - + - - + - + @@ -17,17 +16,17 @@ - + - + - + @@ -35,7 +34,7 @@ - + @@ -50,7 +49,7 @@ - + @@ -58,7 +57,7 @@ - + @@ -66,7 +65,7 @@ - + @@ -77,7 +76,7 @@ - + diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index ce6a63050..5b2a00ebe 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -1,7 +1,7 @@ - + - + @@ -70,7 +70,13 @@ - + + + + + + + diff --git a/Mac/Base.lproj/MainWindow.storyboard b/Mac/Base.lproj/MainWindow.storyboard index eb5960822..57f6d53b4 100644 --- a/Mac/Base.lproj/MainWindow.storyboard +++ b/Mac/Base.lproj/MainWindow.storyboard @@ -1,7 +1,7 @@ - + - + @@ -59,7 +59,7 @@ - + @@ -318,7 +318,7 @@ - + @@ -329,7 +329,6 @@ - @@ -470,7 +469,7 @@ - + @@ -485,7 +484,7 @@ - +