Merge pull request #1144 from kielgillard/master

Syncs articles in pages, decouples article syncing from status syncin…
This commit is contained in:
Maurice Parker 2019-11-04 14:29:22 -06:00 committed by GitHub
commit 219e5751a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 81508 additions and 976 deletions

View File

@ -76,6 +76,10 @@
84EAC4822148CC6300F154AB /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EAC4812148CC6300F154AB /* RSDatabase.framework */; };
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; };
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */; };
9E03C11C235D921400FB6D9E /* FeedlyOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E03C11B235D921400FB6D9E /* FeedlyOperationTests.swift */; };
9E03C11E235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E03C11D235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift */; };
9E03C120235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E03C11F235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift */; };
9E03C122235E62E100FB6D9E /* FeedlyTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E03C121235E62E100FB6D9E /* FeedlyTestSupport.swift */; };
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */; };
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D22345700E0056A5A8 /* FeedlyLink.swift */; };
9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */; };
@ -83,29 +87,38 @@
9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */; };
9E1773DB234593CF0056A5A8 /* FeedlyResourceIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */; };
9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */; };
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */; };
9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154C233370D800F4944C /* FeedlySyncAllOperation.swift */; };
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */; };
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */; };
9E1D15532334304B00F4944C /* FeedlyGetStreamOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */; };
9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15522334304B00F4944C /* FeedlyGetStreamContentsOperation.swift */; };
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D1554233431A600F4944C /* FeedlyOperation.swift */; };
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */; };
9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */; };
9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */; };
9E1FF8602368216B00834C24 /* TestGetStreamIdsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1FF85F2368216B00834C24 /* TestGetStreamIdsService.swift */; };
9E1FF8622368219B00834C24 /* TestGetPagedStreamIdsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1FF8612368219B00834C24 /* TestGetPagedStreamIdsService.swift */; };
9E1FF8642368EC2400834C24 /* FeedlySyncAllOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1FF8632368EC2400834C24 /* FeedlySyncAllOperationTests.swift */; };
9E1FF8662368ED7E00834C24 /* TestMarkArticlesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1FF8652368ED7E00834C24 /* TestMarkArticlesService.swift */; };
9E1FF8682368EE4900834C24 /* TestGetCollectionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1FF8672368EE4900834C24 /* TestGetCollectionsService.swift */; };
9E3CFFFD2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3CFFFC2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift */; };
9E4828F223617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4828F123617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift */; };
9E489E8D2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E489E8C2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift */; };
9E489E912360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E489E902360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift */; };
9E489E93236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E489E92236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift */; };
9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */; };
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */; };
9E5ABE9A236BE6BD00B5DE9F /* feedly-1-initial in Resources */ = {isa = PBXBuildFile; fileRef = 9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */; };
9E713653233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E713652233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift */; };
9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */; };
9E7299D9235062A200DAEFB7 /* FeedlyResourceProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */; };
9E7F15072341E96700F860D1 /* AccountFeedlySyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */; };
9E7F150A2341EF5A00F860D1 /* feedly_collections_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */; };
9E7F150D2341F32000F860D1 /* macintosh_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F150C2341F32000F860D1 /* macintosh_initial.json */; };
9E7F15112341F39A00F860D1 /* uncategorized_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15102341F39A00F860D1 /* uncategorized_initial.json */; };
9E7F15132341F3D900F860D1 /* programming_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15122341F3D900F860D1 /* programming_initial.json */; };
9E7F15152341F42000F860D1 /* weblogs_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15142341F42000F860D1 /* weblogs_initial.json */; };
9E7F15172341F48900F860D1 /* mustread_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E7F15162341F48900F860D1 /* mustread_initial.json */; };
9E832B1E2343467900D83249 /* feedly_collections_addcollection.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */; };
9E832B202343476A00D83249 /* newcollection_addcollection.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B1F2343476A00D83249 /* newcollection_addcollection.json */; };
9E832B23234416B400D83249 /* feedly_collections_addfeed.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B22234416B400D83249 /* feedly_collections_addfeed.json */; };
9E832B25234416FF00D83249 /* mustread_addfeed.json in Resources */ = {isa = PBXBuildFile; fileRef = 9E832B24234416FF00D83249 /* mustread_addfeed.json */; };
9E7F88AC235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7F88AB235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift */; };
9E7F88AE235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7F88AD235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift */; };
9E84DC472359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E84DC462359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift */; };
9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E84DC482359A73600D6E809 /* FeedlyCheckpointOperation.swift */; };
9E85C8E42366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E85C8E32366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift */; };
9E85C8E62366FED600D0F1F7 /* TestGetStreamContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E85C8E52366FED600D0F1F7 /* TestGetStreamContentsService.swift */; };
9E85C8E82366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E85C8E72366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift */; };
9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E85C8E9236700AD00D0F1F7 /* FeedlyGetEntriesOperation.swift */; };
9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E85C8EC2367020700D0F1F7 /* FeedlyGetEntriesService.swift */; };
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */; };
9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */; };
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */; };
@ -113,14 +126,33 @@
9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC625233318400085D7C9 /* FeedlyStream.swift */; };
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */; };
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */; };
9EC228552362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC228542362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift */; };
9EC228572362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC228562362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift */; };
9EC228592362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC228582362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift */; };
9EC2285B23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC2285A23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift */; };
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */; };
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */; };
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */; };
9EC804E3236C18AB0057CFCB /* FeedlySyncAllMockResponseProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC804E2236C18AB0057CFCB /* FeedlySyncAllMockResponseProvider.swift */; };
9EC804E5236C1A7F0057CFCB /* feedly-2-changestatuses in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804E4236C1A7F0057CFCB /* feedly-2-changestatuses */; };
9EC804E7236C1BA60057CFCB /* feedly-3-changestatusesagain in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804E6236C1BA60057CFCB /* feedly-3-changestatusesagain */; };
9EC804E9236C1CBF0057CFCB /* feedly-4-addfeedsandfolders in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804E8236C1CBE0057CFCB /* feedly-4-addfeedsandfolders */; };
9EC804EB236C1DFB0057CFCB /* feedly-5-removefeedsandfolders in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804EA236C1DFA0057CFCB /* feedly-5-removefeedsandfolders */; };
9EC804ED236C206A0057CFCB /* feedly_collections_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804EC236C206A0057CFCB /* feedly_collections_initial.json */; };
9EC804EF236C20DD0057CFCB /* feedly_macintosh_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804EE236C20DD0057CFCB /* feedly_macintosh_initial.json */; };
9EC804F2236C21320057CFCB /* feedly_unreads_1000.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804F0236C21320057CFCB /* feedly_unreads_1000.json */; };
9EC804F3236C21320057CFCB /* feedly_unreads_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EC804F1236C21320057CFCB /* feedly_unreads_initial.json */; };
9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */; };
9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */; };
9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEAE06D235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift */; };
9EEAE071235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEAE070235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift */; };
9EEAE073235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEAE072235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift */; };
9EEAE075235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEAE074235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift */; };
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */; };
9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */; };
9EEEF75223567CA6009E9D80 /* saved_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EEEF75123567CA6009E9D80 /* saved_initial.json */; };
9EF1B10323584B4C000A486A /* FeedlySyncStreamContentsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF1B10223584B4C000A486A /* FeedlySyncStreamContentsOperation.swift */; };
9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF1B10623590D61000A486A /* FeedlyGetStreamIdsOperation.swift */; };
9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF1B10823590E93000A486A /* FeedlyStreamIds.swift */; };
9EF35F7A234E830E003AE2AE /* FeedlyCompoundOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */; };
/* End PBXBuildFile section */
@ -243,6 +275,10 @@
84D09622217418DC00D77525 /* FeedbinTagging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTagging.swift; sourceTree = "<group>"; };
84EAC4812148CC6300F154AB /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleFetcher.swift; sourceTree = "<group>"; };
9E03C11B235D921400FB6D9E /* FeedlyOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOperationTests.swift; sourceTree = "<group>"; };
9E03C11D235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsOperationTests.swift; sourceTree = "<group>"; };
9E03C11F235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMirrorCollectionsAsFoldersOperationTests.swift; sourceTree = "<group>"; };
9E03C121235E62E100FB6D9E /* FeedlyTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyTestSupport.swift; sourceTree = "<group>"; };
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCreateFeedsForCollectionFoldersOperation.swift; sourceTree = "<group>"; };
9E1773D22345700E0056A5A8 /* FeedlyLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyLink.swift; sourceTree = "<group>"; };
9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntryParser.swift; sourceTree = "<group>"; };
@ -250,29 +286,38 @@
9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceId.swift; sourceTree = "<group>"; };
9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceIdTests.swift; sourceTree = "<group>"; };
9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetStarredArticlesOperation.swift; sourceTree = "<group>"; };
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStrategy.swift; sourceTree = "<group>"; };
9E1D154C233370D800F4944C /* FeedlySyncAllOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncAllOperation.swift; sourceTree = "<group>"; };
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsOperation.swift; sourceTree = "<group>"; };
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMirrorCollectionsAsFoldersOperation.swift; sourceTree = "<group>"; };
9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamOperation.swift; sourceTree = "<group>"; };
9E1D15522334304B00F4944C /* FeedlyGetStreamContentsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamContentsOperation.swift; sourceTree = "<group>"; };
9E1D1554233431A600F4944C /* FeedlyOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOperation.swift; sourceTree = "<group>"; };
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRequestStreamsOperation.swift; sourceTree = "<group>"; };
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrganiseParsedItemsByFeedOperation.swift; sourceTree = "<group>"; };
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyUpdateAccountFeedsWithItemsOperation.swift; sourceTree = "<group>"; };
9E1FF85F2368216B00834C24 /* TestGetStreamIdsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetStreamIdsService.swift; sourceTree = "<group>"; };
9E1FF8612368219B00834C24 /* TestGetPagedStreamIdsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetPagedStreamIdsService.swift; sourceTree = "<group>"; };
9E1FF8632368EC2400834C24 /* FeedlySyncAllOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncAllOperationTests.swift; sourceTree = "<group>"; };
9E1FF8652368ED7E00834C24 /* TestMarkArticlesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMarkArticlesService.swift; sourceTree = "<group>"; };
9E1FF8672368EE4900834C24 /* TestGetCollectionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetCollectionsService.swift; sourceTree = "<group>"; };
9E3CFFFC2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncUnreadStatusesOperationTests.swift; sourceTree = "<group>"; };
9E4828F123617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetUnreadArticlesOperationTests.swift; sourceTree = "<group>"; };
9E489E8C2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsOperationTests.swift; sourceTree = "<group>"; };
9E489E902360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrganiseParsedItemsByFeedOperationTests.swift; sourceTree = "<group>"; };
9E489E92236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyUpdateAccountFeedsWithItemsOperationTests.swift; sourceTree = "<group>"; };
9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddFeedRequest.swift; sourceTree = "<group>"; };
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshStreamEntriesStatusOperation.swift; sourceTree = "<group>"; };
9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-1-initial"; sourceTree = "<group>"; };
9E713652233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetUnreadArticlesOperation.swift; sourceTree = "<group>"; };
9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddFeedOperation.swift; sourceTree = "<group>"; };
9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceProviding.swift; sourceTree = "<group>"; };
9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedlySyncTest.swift; sourceTree = "<group>"; };
9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_initial.json; sourceTree = "<group>"; };
9E7F150C2341F32000F860D1 /* macintosh_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = macintosh_initial.json; sourceTree = "<group>"; };
9E7F15102341F39A00F860D1 /* uncategorized_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = uncategorized_initial.json; sourceTree = "<group>"; };
9E7F15122341F3D900F860D1 /* programming_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = programming_initial.json; sourceTree = "<group>"; };
9E7F15142341F42000F860D1 /* weblogs_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = weblogs_initial.json; sourceTree = "<group>"; };
9E7F15162341F48900F860D1 /* mustread_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = mustread_initial.json; sourceTree = "<group>"; };
9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_addcollection.json; sourceTree = "<group>"; };
9E832B1F2343476A00D83249 /* newcollection_addcollection.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = newcollection_addcollection.json; sourceTree = "<group>"; };
9E832B22234416B400D83249 /* feedly_collections_addfeed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_addfeed.json; sourceTree = "<group>"; };
9E832B24234416FF00D83249 /* mustread_addfeed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = mustread_addfeed.json; sourceTree = "<group>"; };
9E7F88AB235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCreateFeedsForCollectionFoldersOperationTests.swift; sourceTree = "<group>"; };
9E7F88AD235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamContentsOperationTests.swift; sourceTree = "<group>"; };
9E84DC462359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncUnreadStatusesOperation.swift; sourceTree = "<group>"; };
9E84DC482359A73600D6E809 /* FeedlyCheckpointOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCheckpointOperation.swift; sourceTree = "<group>"; };
9E85C8E32366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStarredArticlesOperationTests.swift; sourceTree = "<group>"; };
9E85C8E52366FED600D0F1F7 /* TestGetStreamContentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetStreamContentsService.swift; sourceTree = "<group>"; };
9E85C8E72366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetPagedStreamContentsService.swift; sourceTree = "<group>"; };
9E85C8E9236700AD00D0F1F7 /* FeedlyGetEntriesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetEntriesOperation.swift; sourceTree = "<group>"; };
9E85C8EC2367020700D0F1F7 /* FeedlyGetEntriesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetEntriesService.swift; sourceTree = "<group>"; };
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegate.swift; sourceTree = "<group>"; };
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollection.swift; sourceTree = "<group>"; };
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeed.swift; sourceTree = "<group>"; };
@ -280,14 +325,33 @@
9EAEC625233318400085D7C9 /* FeedlyStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyStream.swift; sourceTree = "<group>"; };
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCategory.swift; sourceTree = "<group>"; };
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrigin.swift; sourceTree = "<group>"; };
9EC228542362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetStarredArticlesOperationTests.swift; sourceTree = "<group>"; };
9EC228562362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCheckpointOperationTests.swift; sourceTree = "<group>"; };
9EC228582362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySendArticleStatusesOperationTests.swift; sourceTree = "<group>"; };
9EC2285A23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStreamContentsOperationTests.swift; sourceTree = "<group>"; };
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = "<group>"; };
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = "<group>"; };
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = "<group>"; };
9EC804E2236C18AB0057CFCB /* FeedlySyncAllMockResponseProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncAllMockResponseProvider.swift; sourceTree = "<group>"; };
9EC804E4236C1A7F0057CFCB /* feedly-2-changestatuses */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-2-changestatuses"; sourceTree = "<group>"; };
9EC804E6236C1BA60057CFCB /* feedly-3-changestatusesagain */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-3-changestatusesagain"; sourceTree = "<group>"; };
9EC804E8236C1CBE0057CFCB /* feedly-4-addfeedsandfolders */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-4-addfeedsandfolders"; sourceTree = "<group>"; };
9EC804EA236C1DFA0057CFCB /* feedly-5-removefeedsandfolders */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-5-removefeedsandfolders"; sourceTree = "<group>"; };
9EC804EC236C206A0057CFCB /* feedly_collections_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_collections_initial.json; sourceTree = "<group>"; };
9EC804EE236C20DD0057CFCB /* feedly_macintosh_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_macintosh_initial.json; sourceTree = "<group>"; };
9EC804F0236C21320057CFCB /* feedly_unreads_1000.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_unreads_1000.json; sourceTree = "<group>"; };
9EC804F1236C21320057CFCB /* feedly_unreads_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedly_unreads_initial.json; sourceTree = "<group>"; };
9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegateError.swift; sourceTree = "<group>"; };
9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeedContainerValidator.swift; sourceTree = "<group>"; };
9EEAE06D235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsService.swift; sourceTree = "<group>"; };
9EEAE070235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamContentsService.swift; sourceTree = "<group>"; };
9EEAE072235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsService.swift; sourceTree = "<group>"; };
9EEAE074235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMarkArticlesService.swift; sourceTree = "<group>"; };
9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySendArticleStatusesOperation.swift; sourceTree = "<group>"; };
9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStarredArticlesOperation.swift; sourceTree = "<group>"; };
9EEEF75123567CA6009E9D80 /* saved_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = saved_initial.json; sourceTree = "<group>"; };
9EF1B10223584B4C000A486A /* FeedlySyncStreamContentsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStreamContentsOperation.swift; sourceTree = "<group>"; };
9EF1B10623590D61000A486A /* FeedlyGetStreamIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsOperation.swift; sourceTree = "<group>"; };
9EF1B10823590E93000A486A /* FeedlyStreamIds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyStreamIds.swift; sourceTree = "<group>"; };
9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCompoundOperation.swift; sourceTree = "<group>"; };
D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = "<group>"; };
D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = "<group>"; };
@ -355,6 +419,10 @@
51D58756227F62E300900287 /* JSON */ = {
isa = PBXGroup;
children = (
9EC804F0236C21320057CFCB /* feedly_unreads_1000.json */,
9EC804F1236C21320057CFCB /* feedly_unreads_initial.json */,
9EC804EE236C20DD0057CFCB /* feedly_macintosh_initial.json */,
9EC804EC236C206A0057CFCB /* feedly_collections_initial.json */,
5133230D2281089500C30F19 /* icons.json */,
5133230B2281088A00C30F19 /* subscriptions_add.json */,
513323092281082F00C30F19 /* subscriptions_initial.json */,
@ -506,47 +574,40 @@
9E7F15082341E97100F860D1 /* Feedly */ = {
isa = PBXGroup;
children = (
9E7F15062341E96700F860D1 /* AccountFeedlySyncTest.swift */,
9E03C121235E62E100FB6D9E /* FeedlyTestSupport.swift */,
9E85C8E52366FED600D0F1F7 /* TestGetStreamContentsService.swift */,
9E1FF85F2368216B00834C24 /* TestGetStreamIdsService.swift */,
9E85C8E72366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift */,
9E1FF8612368219B00834C24 /* TestGetPagedStreamIdsService.swift */,
9E1FF8672368EE4900834C24 /* TestGetCollectionsService.swift */,
9E1FF8652368ED7E00834C24 /* TestMarkArticlesService.swift */,
9E03C11B235D921400FB6D9E /* FeedlyOperationTests.swift */,
9E03C11D235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift */,
9E03C11F235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift */,
9E7F88AB235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift */,
9E7F88AD235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift */,
9E489E8C2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift */,
9E489E902360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift */,
9E489E92236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift */,
9E4828F123617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift */,
9EC228542362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift */,
9EC228562362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift */,
9EC228582362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift */,
9EC2285A23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift */,
9E85C8E32366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift */,
9E3CFFFC2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift */,
9E1FF8632368EC2400834C24 /* FeedlySyncAllOperationTests.swift */,
9EC804E2236C18AB0057CFCB /* FeedlySyncAllMockResponseProvider.swift */,
9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */,
9E7F150B2341F2A700F860D1 /* Initial */,
9E832B1A234344DA00D83249 /* AddCollection */,
9E832B21234416B400D83249 /* AddFeed */,
9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */,
9EC804E4236C1A7F0057CFCB /* feedly-2-changestatuses */,
9EC804E6236C1BA60057CFCB /* feedly-3-changestatusesagain */,
9EC804E8236C1CBE0057CFCB /* feedly-4-addfeedsandfolders */,
9EC804EA236C1DFA0057CFCB /* feedly-5-removefeedsandfolders */,
);
path = Feedly;
sourceTree = "<group>";
};
9E7F150B2341F2A700F860D1 /* Initial */ = {
isa = PBXGroup;
children = (
9EEEF75123567CA6009E9D80 /* saved_initial.json */,
9E7F15092341EF5A00F860D1 /* feedly_collections_initial.json */,
9E7F150C2341F32000F860D1 /* macintosh_initial.json */,
9E7F15162341F48900F860D1 /* mustread_initial.json */,
9E7F15122341F3D900F860D1 /* programming_initial.json */,
9E7F15102341F39A00F860D1 /* uncategorized_initial.json */,
9E7F15142341F42000F860D1 /* weblogs_initial.json */,
);
path = Initial;
sourceTree = "<group>";
};
9E832B1A234344DA00D83249 /* AddCollection */ = {
isa = PBXGroup;
children = (
9E832B1D2343467900D83249 /* feedly_collections_addcollection.json */,
9E832B1F2343476A00D83249 /* newcollection_addcollection.json */,
);
path = AddCollection;
sourceTree = "<group>";
};
9E832B21234416B400D83249 /* AddFeed */ = {
isa = PBXGroup;
children = (
9E832B22234416B400D83249 /* feedly_collections_addfeed.json */,
9E832B24234416FF00D83249 /* mustread_addfeed.json */,
);
path = AddFeed;
sourceTree = "<group>";
};
9EA31339231E368100268BA0 /* Feedly */ = {
isa = PBXGroup;
children = (
@ -557,33 +618,39 @@
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */,
9E510D6D234F16A8002E6F1A /* FeedlyAddFeedRequest.swift */,
9E1D1554233431A600F4944C /* FeedlyOperation.swift */,
9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */,
9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */,
9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */,
9EEAE06F235D003400E3FEE4 /* Services */,
9EBC31B32338AC2E002A567B /* Models */,
9EBC31B22338AC0F002A567B /* Refresh */,
9EBC31B22338AC0F002A567B /* Operations */,
);
path = Feedly;
sourceTree = SOURCE_ROOT;
};
9EBC31B22338AC0F002A567B /* Refresh */ = {
9EBC31B22338AC0F002A567B /* Operations */ = {
isa = PBXGroup;
children = (
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */,
9E1D1554233431A600F4944C /* FeedlyOperation.swift */,
9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */,
9E7299D623505E9600DAEFB7 /* FeedlyAddFeedOperation.swift */,
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */,
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */,
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */,
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */,
9E1D15522334304B00F4944C /* FeedlyGetStreamOperation.swift */,
9E1D15522334304B00F4944C /* FeedlyGetStreamContentsOperation.swift */,
9EF1B10623590D61000A486A /* FeedlyGetStreamIdsOperation.swift */,
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */,
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */,
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */,
9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */,
9E85C8E9236700AD00D0F1F7 /* FeedlyGetEntriesOperation.swift */,
9E713652233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift */,
9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */,
9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */,
9E84DC482359A73600D6E809 /* FeedlyCheckpointOperation.swift */,
9EF1B10223584B4C000A486A /* FeedlySyncStreamContentsOperation.swift */,
9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */,
9E84DC462359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift */,
9E1D154C233370D800F4944C /* FeedlySyncAllOperation.swift */,
);
path = Refresh;
path = Operations;
sourceTree = "<group>";
};
9EBC31B32338AC2E002A567B /* Models */ = {
@ -595,6 +662,7 @@
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */,
9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */,
9EAEC625233318400085D7C9 /* FeedlyStream.swift */,
9EF1B10823590E93000A486A /* FeedlyStreamIds.swift */,
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */,
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */,
9E1773D22345700E0056A5A8 /* FeedlyLink.swift */,
@ -603,6 +671,18 @@
path = Models;
sourceTree = "<group>";
};
9EEAE06F235D003400E3FEE4 /* Services */ = {
isa = PBXGroup;
children = (
9EEAE06D235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift */,
9EEAE070235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift */,
9E85C8EC2367020700D0F1F7 /* FeedlyGetEntriesService.swift */,
9EEAE072235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift */,
9EEAE074235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift */,
);
path = Services;
sourceTree = "<group>";
};
D511EEB4202422BB00712EC3 /* xcconfig */ = {
isa = PBXGroup;
children = (
@ -679,11 +759,11 @@
848934F51F62484F00CEBD24 = {
CreatedOnToolsVersion = 9.0;
LastSwiftMigration = 0900;
ProvisioningStyle = Manual;
ProvisioningStyle = Automatic;
};
848934FE1F62484F00CEBD24 = {
CreatedOnToolsVersion = 9.0;
ProvisioningStyle = Manual;
ProvisioningStyle = Automatic;
};
};
};
@ -765,25 +845,23 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9EC804ED236C206A0057CFCB /* feedly_collections_initial.json in Resources */,
9E5ABE9A236BE6BD00B5DE9F /* feedly-1-initial in Resources */,
5165D71822821C2400D9D53D /* taggings_initial.json in Resources */,
5133230E2281089500C30F19 /* icons.json in Resources */,
9EC804F3236C21320057CFCB /* feedly_unreads_initial.json in Resources */,
51D5875B227F630B00900287 /* tags_add.json in Resources */,
9EC804E9236C1CBF0057CFCB /* feedly-4-addfeedsandfolders in Resources */,
9EC804EB236C1DFB0057CFCB /* feedly-5-removefeedsandfolders in Resources */,
5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */,
9E832B202343476A00D83249 /* newcollection_addcollection.json in Resources */,
9E832B1E2343467900D83249 /* feedly_collections_addcollection.json in Resources */,
9E7F15152341F42000F860D1 /* weblogs_initial.json in Resources */,
51D5875C227F630B00900287 /* tags_initial.json in Resources */,
9EC804E5236C1A7F0057CFCB /* feedly-2-changestatuses in Resources */,
51D5875A227F630B00900287 /* tags_delete.json in Resources */,
9EC804E7236C1BA60057CFCB /* feedly-3-changestatusesagain in Resources */,
9EC804EF236C20DD0057CFCB /* feedly_macintosh_initial.json in Resources */,
5165D71722821C2400D9D53D /* taggings_add.json in Resources */,
9EEEF75223567CA6009E9D80 /* saved_initial.json in Resources */,
9E832B23234416B400D83249 /* feedly_collections_addfeed.json in Resources */,
5165D71622821C2400D9D53D /* taggings_delete.json in Resources */,
9E7F15112341F39A00F860D1 /* uncategorized_initial.json in Resources */,
9E7F15132341F3D900F860D1 /* programming_initial.json in Resources */,
9E832B25234416FF00D83249 /* mustread_addfeed.json in Resources */,
9E7F150A2341EF5A00F860D1 /* feedly_collections_initial.json in Resources */,
9E7F15172341F48900F860D1 /* mustread_initial.json in Resources */,
9E7F150D2341F32000F860D1 /* macintosh_initial.json in Resources */,
9EC804F2236C21320057CFCB /* feedly_unreads_1000.json in Resources */,
5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -821,6 +899,7 @@
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */,
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */,
9EEAE071235D019B00E3FEE4 /* FeedlyGetStreamContentsService.swift in Sources */,
9E7299D9235062A200DAEFB7 /* FeedlyResourceProviding.swift in Sources */,
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
@ -830,12 +909,12 @@
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */,
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
9E1D15532334304B00F4944C /* FeedlyGetStreamOperation.swift in Sources */,
9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */,
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */,
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */,
9E713653233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */,
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
@ -843,17 +922,25 @@
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 */,
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */,
9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */,
9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */,
9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */,
9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */,
844B297D2106C7EC004020B3 /* Feed.swift in Sources */,
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */,
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */,
9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */,
515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */,
9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */,
9EEAE075235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift in Sources */,
9EF1B10323584B4C000A486A /* FeedlySyncStreamContentsOperation.swift in Sources */,
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */,
84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */,
9E84DC472359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift in Sources */,
9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */,
9EEAE073235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift in Sources */,
9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */,
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */,
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
@ -882,6 +969,7 @@
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */,
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */,
9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */,
@ -894,6 +982,7 @@
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */,
9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */,
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */,
9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */,
84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -902,14 +991,37 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9E7F15072341E96700F860D1 /* AccountFeedlySyncTest.swift in Sources */,
9EC228572362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift in Sources */,
9E03C122235E62E100FB6D9E /* FeedlyTestSupport.swift in Sources */,
9E3CFFFD2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift in Sources */,
9EC228552362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift in Sources */,
9E03C120235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift in Sources */,
9E489E912360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift in Sources */,
9E1FF8622368219B00834C24 /* TestGetPagedStreamIdsService.swift in Sources */,
9E7F88AC235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift in Sources */,
9E03C11E235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift in Sources */,
9E85C8E62366FED600D0F1F7 /* TestGetStreamContentsService.swift in Sources */,
9E1FF8662368ED7E00834C24 /* TestMarkArticlesService.swift in Sources */,
9E03C11C235D921400FB6D9E /* FeedlyOperationTests.swift in Sources */,
9E1FF8642368EC2400834C24 /* FeedlySyncAllOperationTests.swift in Sources */,
9E1FF8602368216B00834C24 /* TestGetStreamIdsService.swift in Sources */,
9E85C8E82366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift in Sources */,
9EC228592362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift in Sources */,
5165D7122282080C00D9D53D /* AccountFeedbinFolderContentsSyncTest.swift in Sources */,
9E489E93236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift in Sources */,
9E489E8D2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift in Sources */,
51D5875E227F643C00900287 /* AccountFeedbinFolderSyncTest.swift in Sources */,
9EC804E3236C18AB0057CFCB /* FeedlySyncAllMockResponseProvider.swift in Sources */,
9E1FF8682368EE4900834C24 /* TestGetCollectionsService.swift in Sources */,
9E4828F223617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift in Sources */,
5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */,
513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */,
9E85C8E42366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift in Sources */,
5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */,
5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */,
9EC2285B23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift in Sources */,
9E1773DB234593CF0056A5A8 /* FeedlyResourceIdTests.swift in Sources */,
9E7F88AE235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -1,324 +0,0 @@
//
// AccountFeedlySyncTest.swift
// AccountTests
//
// Created by Kiel Gillard on 30/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import Articles
class AccountFeedlySyncTest: XCTestCase {
private let testTransport = TestTransport()
private var account: Account!
override func setUp() {
super.setUp()
account = TestAccountManager.shared.createAccount(type: .feedly, transport: testTransport)
do {
let username = UUID().uuidString
let credentials = Credentials(type: .oauthAccessToken, username: username, secret: "test")
try account.storeCredentials(credentials)
} catch {
XCTFail("Unable to register mock credentials because \(error)")
}
}
override func tearDown() {
// Clean up
do {
try account.removeCredentials(type: .oauthAccessToken)
} catch {
XCTFail("Unable to clean up mock credentials because \(error)")
}
TestAccountManager.shared.deleteAccount(account)
super.tearDown()
}
// MARK: Initial Sync
func testInitialSync() {
XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh account without any existing feeds.")
XCTAssertTrue((account.folders ?? Set()).isEmpty, "Expected to be testing a fresh account without any existing folders.")
set(testFiles: .initial, with: testTransport)
// Test initial folders for collections and feeds for collection feeds.
let initialExpection = self.expectation(description: "Initial feeds")
account.refreshAll() { _ in
initialExpection.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
checkArticles(againstItemsInStreamInJSONNamed: "macintosh_initial")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
checkArticles(againstItemsInStreamInJSONNamed: "programming_initial")
checkArticles(againstItemsInStreamInJSONNamed: "uncategorized_initial")
checkArticles(againstItemsInStreamInJSONNamed: "weblogs_initial")
}
// MARK: Add Collection
func testAddsFoldersForCollections() {
prepareBaseline(.initial)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
set(testFiles: .addCollection, with: testTransport)
let addCollectionExpectation = self.expectation(description: "Adds NewCollection")
account.refreshAll() { _ in
addCollectionExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "newcollection_addcollection")
}
// MARK: Add Feed
func testAddsFeeds() {
prepareBaseline(.addCollection)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
set(testFiles: .addFeed, with: testTransport)
let addFeedExpectation = self.expectation(description: "Add Feed To Must Read (hey, that rhymes!)")
account.refreshAll() { _ in
addFeedExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed")
}
// MARK: Remove Feed
func testRemovesFeeds() {
prepareBaseline(.addFeed)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed")
set(testFiles: .removeFeed, with: testTransport)
let removeFeedExpectation = self.expectation(description: "Remove Feed from Must Read")
account.refreshAll() { _ in
removeFeedExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection")
checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial")
}
func testRemoveCollection() {
prepareBaseline(.addFeed)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed")
set(testFiles: .removeCollection, with: testTransport)
let removeCollectionExpectation = self.expectation(description: "Remove Collection")
account.refreshAll() { _ in
removeCollectionExpectation.fulfill()
}
waitForExpectations(timeout: 5)
checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial")
}
// MARK: Utility
func prepareBaseline(_ testFiles: TestFiles) {
XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh accout.")
set(testFiles: testFiles, with: testTransport)
// Test initial folders for collections and feeds for collection feeds.
let preparationExpectation = self.expectation(description: "Prepare Account")
account.refreshAll() { _ in
preparationExpectation.fulfill()
}
// If there's a failure here, then an operation hasn't completed.
// Check that test files have responses for all the requests this might make.
waitForExpectations(timeout: 5)
}
func checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed name: String) {
let collections = testJSON(named: name) as! [[String:Any]]
let collectionNames = Set(collections.map { $0["label"] as! String })
let collectionIds = Set(collections.map { $0["id"] as! String })
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.name })
let folderIds = Set(folders.compactMap { $0.externalID })
let missingNames = collectionNames.subtracting(folderNames)
let missingIds = collectionIds.subtracting(folderIds)
XCTAssertEqual(folders.count, collections.count, "Mismatch between collections and folders.")
XCTAssertTrue(missingNames.isEmpty, "Collections with these names did not have a corresponding folder with the same name.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids did not have a corresponding folder with the same id.")
for collection in collections {
checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload: collection)
}
}
func checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONNamed name: String) {
let collection = testJSON(named: name) as! [String:Any]
checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload: collection)
}
func checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload collection: [String: Any]) {
let label = collection["label"] as! String
guard let folder = account.existingFolder(with: label) else {
// due to a previous test failure?
XCTFail("Could not find the \"\(label)\" folder.")
return
}
let collectionFeeds = collection["feeds"] as! [[String: Any]]
let folderFeeds = folder.topLevelFeeds
XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
let folderFeedIds = Set(folderFeeds.map { $0.feedID })
let missingFeedIds = collectionFeedIds.subtracting(folderFeedIds)
XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
}
func checkArticles(againstItemsInStreamInJSONNamed name: String) {
let stream = testJSON(named: name) as! [String:Any]
checkArticles(againstItemsInStreamInJSONPayload: stream)
}
func checkArticles(againstItemsInStreamInJSONPayload stream: [String: Any]) {
struct ArticleItem {
var id: String
var feedId: String
var content: String
var JSON: [String: Any]
var unread: Bool
/// Convoluted external URL logic "documented" here:
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var externalUrl: String? {
return ((JSON["canonical"] as? [[String: Any]]) ?? (JSON["alternate"] as? [[String: Any]]))?.compactMap { link -> String? in
let href = link["href"] as? String
if let type = link["type"] as? String {
if type == "text/html" {
return href
}
return nil
}
return href
}.first
}
init(item: [String: Any]) {
self.JSON = item
self.id = item["id"] as! String
let origin = item["origin"] as! [String: Any]
self.feedId = origin["streamId"] as! String
let content = item["content"] as? [String: Any]
let summary = item["summary"] as? [String: Any]
self.content = ((content ?? summary)?["content"] as? String) ?? ""
self.unread = item["unread"] as! Bool
}
}
let items = stream["items"] as! [[String: Any]]
let articleItems = items.map { ArticleItem(item: $0) }
let itemIds = Set(articleItems.map { $0.id })
let articles = account.fetchArticles(.articleIDs(itemIds))
let articleIds = Set(articles.map { $0.articleID })
let missing = itemIds.subtracting(articleIds)
XCTAssertEqual(items.count, articles.count)
XCTAssertTrue(missing.isEmpty, "Items with these ids did not have a corresponding article with the same id.")
for article in articles {
for item in articleItems where item.id == article.articleID {
XCTAssertEqual(article.uniqueID, item.id)
XCTAssertEqual(article.contentHTML, item.content)
XCTAssertEqual(article.feedID, item.feedId)
XCTAssertEqual(article.externalURL, item.externalUrl)
// XCTAssertEqual(article.status.boolStatus(forKey: .read), item.unread)
}
}
}
func testJSON(named: String) -> Any {
let bundle = Bundle(for: TestTransport.self)
let url = bundle.url(forResource: named, withExtension: "json")!
let data = try! Data(contentsOf: url)
let json = try! JSONSerialization.jsonObject(with: data)
return json
}
enum TestFiles {
case initial
case addCollection
case addFeed
case removeFeed
case removeCollection
}
func set(testFiles: TestFiles, with transport: TestTransport) {
// TestTransport blacklists certain query items to make mocking responses easier.
let collectionsEndpoint = "/v3/collections"
switch testFiles {
case .initial:
let dict = [
"/global.saved": "saved_initial.json",
collectionsEndpoint: "feedly_collections_initial.json",
"/5ca4d61d-e55d-4999-a8d1-c3b9d8789815": "macintosh_initial.json",
"/global.must": "mustread_initial.json",
"/885f2e01-d314-4e63-abac-17dcb063f5b5": "programming_initial.json",
"/66132046-6f14-488d-b590-8e93422723c8": "uncategorized_initial.json",
"/e31b3fcb-27f6-4f3e-b96c-53902586e366": "weblogs_initial.json",
]
transport.testFiles = dict
case .addCollection:
set(testFiles: .initial, with: transport)
var dict = transport.testFiles
dict[collectionsEndpoint] = "feedly_collections_addcollection.json"
dict["/fc09f383-5a9a-4daa-a575-3efc1733b173"] = "newcollection_addcollection.json"
transport.testFiles = dict
case .addFeed:
set(testFiles: .addCollection, with: transport)
var dict = transport.testFiles
dict[collectionsEndpoint] = "feedly_collections_addfeed.json"
dict["/global.must"] = "mustread_addfeed.json"
transport.testFiles = dict
case .removeFeed:
set(testFiles: .addCollection, with: transport)
case .removeCollection:
set(testFiles: .initial, with: transport)
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
//
// FeedlyCheckpointOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 25/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyCheckpointOperationTests: XCTestCase {
class TestDelegate: FeedlyCheckpointOperationDelegate {
var didReachCheckpointExpectation: XCTestExpectation?
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
didReachCheckpointExpectation?.fulfill()
}
}
func testCallback() {
let delegate = TestDelegate()
delegate.didReachCheckpointExpectation = expectation(description: "Did Reach Checkpoint")
let operation = FeedlyCheckpointOperation()
operation.checkpointDelegate = delegate
let didFinishExpectation = expectation(description: "Did Finish")
operation.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(operation)
waitForExpectations(timeout: 2)
}
func testCancellation() {
let didReachCheckpointExpectation = expectation(description: "Did Reach Checkpoint")
didReachCheckpointExpectation.isInverted = true
let delegate = TestDelegate()
delegate.didReachCheckpointExpectation = didReachCheckpointExpectation
let operation = FeedlyCheckpointOperation()
operation.checkpointDelegate = delegate
let didFinishExpectation = expectation(description: "Did Finish")
operation.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(operation)
operation.cancel()
waitForExpectations(timeout: 1)
}
}

View File

@ -0,0 +1,195 @@
//
// FeedlyCreateFeedsForCollectionFoldersOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 22/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
class FeedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding {
var feedsAndFolders = [([FeedlyFeed], Folder)]()
}
func testAddFeeds() {
let feedsForFolderOne = [
FeedlyFeed(feedId: "feed/1", id: "feed/1", title: "Feed One", updated: nil, website: nil),
FeedlyFeed(feedId: "feed/2", id: "feed/2", title: "Feed Two", updated: nil, website: nil)
]
let feedsForFolderTwo = [
FeedlyFeed(feedId: "feed/1", id: "feed/1", title: "Feed One", updated: nil, website: nil),
FeedlyFeed(feedId: "feed/3", id: "feed/3", title: "Feed Three", updated: nil, website: nil),
]
let folderOne: (name: String, id: String) = ("FolderOne", "folder/1")
let folderTwo: (name: String, id: String) = ("FolderTwo", "folder/2")
let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
let provider = FeedsAndFoldersProvider()
provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
let accountFolder = account.ensureFolder(with: folder.name)!
accountFolder.externalID = folder.id
return (feeds, accountFolder)
}
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
createFeeds.completionBlock = {
completionExpectation.fulfill()
}
XCTAssertTrue(account.flattenedFeeds().isEmpty, "Expected empty account.")
OperationQueue.main.addOperation(createFeeds)
waitForExpectations(timeout: 2)
let feedIds = Set([feedsForFolderOne, feedsForFolderTwo]
.flatMap { $0 }
.map { $0.feedId })
let feedTitles = Set([feedsForFolderOne, feedsForFolderTwo]
.flatMap { $0 }
.map { $0.title })
let accountFeeds = account.flattenedFeeds()
let ingestedIds = Set(accountFeeds.map { $0.feedID })
let ingestedTitles = Set(accountFeeds.map { $0.nameForDisplay })
let missingIds = feedIds.subtracting(ingestedIds)
let missingTitles = feedTitles.subtracting(ingestedTitles)
XCTAssertTrue(missingIds.isEmpty, "Failed to ingest feeds with these ids.")
XCTAssertTrue(missingTitles.isEmpty, "Failed to ingest feeds with these titles.")
let expectedFolderAndFeedIds = namesAndFeeds
.sorted { $0.0.id < $1.0.id }
.map { folder, feeds -> [String: [String]] in
return [folder.id: feeds.map { $0.feedId }.sorted(by: <)]
}
let ingestedFolderAndFeedIds = (account.folders ?? Set())
.sorted { $0.externalID! < $1.externalID! }
.compactMap { folder -> [String: [String]]? in
return [folder.externalID!: folder.topLevelFeeds.map { $0.feedID }.sorted(by: <)]
}
XCTAssertEqual(expectedFolderAndFeedIds, ingestedFolderAndFeedIds, "Did not ingest feeds in their corresponding folders.")
}
func testRemoveFeeds() {
let folderOne: (name: String, id: String) = ("FolderOne", "folder/1")
let folderTwo: (name: String, id: String) = ("FolderTwo", "folder/2")
let feedToRemove = FeedlyFeed(feedId: "feed/1", id: "feed/1", title: "Feed One", updated: nil, website: nil)
var feedsForFolderOne = [
feedToRemove,
FeedlyFeed(feedId: "feed/2", id: "feed/2", title: "Feed Two", updated: nil, website: nil)
]
var feedsForFolderTwo = [
feedToRemove,
FeedlyFeed(feedId: "feed/3", id: "feed/3", title: "Feed Three", updated: nil, website: nil),
]
// Add initial content.
do {
let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
let provider = FeedsAndFoldersProvider()
provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
let accountFolder = account.ensureFolder(with: folder.name)!
accountFolder.externalID = folder.id
return (feeds, accountFolder)
}
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
createFeeds.completionBlock = {
completionExpectation.fulfill()
}
XCTAssertTrue(account.flattenedFeeds().isEmpty, "Expected empty account.")
OperationQueue.main.addOperation(createFeeds)
waitForExpectations(timeout: 2)
}
feedsForFolderOne.removeAll { $0.id == feedToRemove.id }
feedsForFolderTwo.removeAll { $0.id == feedToRemove.id }
let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
let provider = FeedsAndFoldersProvider()
provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
let accountFolder = account.ensureFolder(with: folder.name)!
accountFolder.externalID = folder.id
return (feeds, accountFolder)
}
let removeFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
removeFeeds.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(removeFeeds)
waitForExpectations(timeout: 2)
let feedIds = Set([feedsForFolderOne, feedsForFolderTwo]
.flatMap { $0 }
.map { $0.feedId })
let feedTitles = Set([feedsForFolderOne, feedsForFolderTwo]
.flatMap { $0 }
.map { $0.title })
let accountFeeds = account.flattenedFeeds()
let ingestedIds = Set(accountFeeds.map { $0.feedID })
let ingestedTitles = Set(accountFeeds.map { $0.nameForDisplay })
XCTAssertEqual(ingestedIds.count, feedIds.count)
XCTAssertEqual(ingestedTitles.count, feedTitles.count)
let missingIds = feedIds.subtracting(ingestedIds)
let missingTitles = feedTitles.subtracting(ingestedTitles)
XCTAssertTrue(missingIds.isEmpty, "Failed to ingest feeds with these ids.")
XCTAssertTrue(missingTitles.isEmpty, "Failed to ingest feeds with these titles.")
let expectedFolderAndFeedIds = namesAndFeeds
.sorted { $0.0.id < $1.0.id }
.map { folder, feeds -> [String: [String]] in
return [folder.id: feeds.map { $0.feedId }.sorted(by: <)]
}
let ingestedFolderAndFeedIds = (account.folders ?? Set())
.sorted { $0.externalID! < $1.externalID! }
.compactMap { folder -> [String: [String]]? in
return [folder.externalID!: folder.topLevelFeeds.map { $0.feedID }.sorted(by: <)]
}
XCTAssertEqual(expectedFolderAndFeedIds, ingestedFolderAndFeedIds, "Did not ingest feeds to their corresponding folders.")
}
}

View File

@ -0,0 +1,92 @@
//
// FeedlyGetCollectionsOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 21/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import os.log
class FeedlyGetCollectionsOperationTests: XCTestCase {
func testGetCollections() {
let support = FeedlyTestSupport()
let (transport, caller) = support.makeMockNetworkStack()
let jsonName = "feedly_collections_initial"
transport.testFiles["/v3/collections"] = "\(jsonName).json"
let getCollections = FeedlyGetCollectionsOperation(service: caller, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
getCollections.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getCollections)
waitForExpectations(timeout: 2)
let collections = support.testJSON(named: jsonName) as! [[String:Any]]
let labelsInJSON = Set(collections.map { $0["label"] as! String })
let idsInJSON = Set(collections.map { $0["id"] as! String })
let labels = Set(getCollections.collections.map { $0.label })
let ids = Set(getCollections.collections.map { $0.id })
let missingLabels = labelsInJSON.subtracting(labels)
let missingIds = idsInJSON.subtracting(ids)
XCTAssertEqual(getCollections.collections.count, collections.count, "Mismatch between collections provided by operation and test JSON collections.")
XCTAssertTrue(missingLabels.isEmpty, "Collections with these labels did not have a corresponding \(FeedlyCollection.self) value with the same name.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids did not have a corresponding \(FeedlyCollection.self) with the same id.")
for collection in collections {
let collectionId = collection["id"] as! String
let collectionFeeds = collection["feeds"] as! [[String: Any]]
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
for operationCollection in getCollections.collections where operationCollection.id == collectionId {
let feedIds = Set(operationCollection.feeds.map { $0.id })
let missingIds = collectionFeedIds.subtracting(feedIds)
XCTAssertTrue(missingIds.isEmpty, "Feeds with these ids were not found in the \"\(operationCollection.label)\" \(FeedlyCollection.self).")
}
}
}
func testGetCollectionsError() {
class TestDelegate: FeedlyOperationDelegate {
var errorExpectation: XCTestExpectation?
var error: Error?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
self.error = error
errorExpectation?.fulfill()
}
}
let delegate = TestDelegate()
delegate.errorExpectation = expectation(description: "Did Fail With Expected Error")
let support = FeedlyTestSupport()
let service = TestGetCollectionsService()
service.mockResult = .failure(URLError(.timedOut))
let getCollections = FeedlyGetCollectionsOperation(service: service, log: support.log)
getCollections.delegate = delegate
let completionExpectation = expectation(description: "Did Finish")
getCollections.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getCollections)
waitForExpectations(timeout: 2)
XCTAssertNotNil(delegate.error)
XCTAssertTrue(getCollections.collections.isEmpty, "Collections should be empty.")
}
}

View File

@ -0,0 +1,131 @@
//
// FeedlyGetStreamContentsOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 23/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyGetStreamContentsOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testGetStreamContentsFailure() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: service, continuation: nil, newerThan: nil, unreadOnly: nil)
service.mockResult = .failure(URLError(.fileDoesNotExist))
let completionExpectation = expectation(description: "Did Finish")
getStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamContents)
waitForExpectations(timeout: 2)
XCTAssertNil(getStreamContents.stream)
}
func testValuesPassingForGetStreamContents() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let continuation: String? = "abcdefg"
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 86)
let unreadOnly: Bool? = true
let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: service, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly)
let mockStream = FeedlyStream(id: "stream/1", updated: nil, continuation: nil, items: [])
service.mockResult = .success(mockStream)
service.getStreamContentsExpectation = expectation(description: "Did Call Service")
service.parameterTester = { serviceResource, serviceContinuation, serviceNewerThan, serviceUnreadOnly in
// Verify these values given to the opeartion are passed to the service.
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertEqual(serviceContinuation, continuation)
XCTAssertEqual(serviceNewerThan, newerThan)
XCTAssertEqual(serviceUnreadOnly, unreadOnly)
}
let completionExpectation = expectation(description: "Did Finish")
getStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamContents)
waitForExpectations(timeout: 2)
guard let stream = getStreamContents.stream else {
XCTFail("\(FeedlyGetStreamContentsOperation.self) did not store the stream.")
return
}
XCTAssertEqual(stream.id, mockStream.id)
XCTAssertEqual(stream.updated, mockStream.updated)
XCTAssertEqual(stream.continuation, mockStream.continuation)
let streamIds = stream.items.map { $0.id }
let mockStreamIds = mockStream.items.map { $0.id }
XCTAssertEqual(streamIds, mockStreamIds)
}
func testGetStreamContentsFromJSON() {
let support = FeedlyTestSupport()
let (transport, caller) = support.makeMockNetworkStack()
let jsonName = "feedly_macintosh_initial"
transport.testFiles["/v3/streams/contents"] = "\(jsonName).json"
let resource = FeedlyCategoryResourceId(id: "user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/5ca4d61d-e55d-4999-a8d1-c3b9d8789815")
let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: caller, continuation: nil, newerThan: nil, unreadOnly: nil)
let completionExpectation = expectation(description: "Did Finish")
getStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamContents)
waitForExpectations(timeout: 2)
// verify entry providing and parsed item providing
guard let stream = getStreamContents.stream else {
return XCTFail("Expected to have stream.")
}
let streamJSON = support.testJSON(named: jsonName) as! [String:Any]
let id = streamJSON["id"] as! String
XCTAssertEqual(stream.id, id)
let milliseconds = streamJSON["updated"] as! Double
let updated = Date(timeIntervalSince1970: TimeInterval(milliseconds / 1000))
XCTAssertEqual(stream.updated, updated)
let continuation = streamJSON["continuation"] as! String
XCTAssertEqual(stream.continuation, continuation)
support.check(getStreamContents.entries, correspondToStreamItemsIn: streamJSON)
support.check(stream.items, correspondToStreamItemsIn: streamJSON)
}
}

View File

@ -0,0 +1,116 @@
//
// FeedlyGetStreamIdsOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 23/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyGetStreamIdsOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testGetStreamIdsFailure() {
let service = TestGetStreamIdsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let getStreamIds = FeedlyGetStreamIdsOperation(account: account, resource: resource, service: service, continuation: nil, newerThan: nil, unreadOnly: nil)
service.mockResult = .failure(URLError(.fileDoesNotExist))
let completionExpectation = expectation(description: "Did Finish")
getStreamIds.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamIds)
waitForExpectations(timeout: 2)
XCTAssertNil(getStreamIds.streamIds)
}
func testValuesPassingForGetStreamIds() {
let service = TestGetStreamIdsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let continuation: String? = "gfdsa"
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 1000)
let unreadOnly: Bool? = false
let getStreamIds = FeedlyGetStreamIdsOperation(account: account, resource: resource, service: service, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly)
let mockStreamIds = FeedlyStreamIds(continuation: "1234", ids: ["item/1", "item/2", "item/3"])
service.mockResult = .success(mockStreamIds)
service.getStreamIdsExpectation = expectation(description: "Did Call Service")
service.parameterTester = { serviceResource, serviceContinuation, serviceNewerThan, serviceUnreadOnly in
// Verify these values given to the opeartion are passed to the service.
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertEqual(serviceContinuation, continuation)
XCTAssertEqual(serviceNewerThan, newerThan)
XCTAssertEqual(serviceUnreadOnly, unreadOnly)
}
let completionExpectation = expectation(description: "Did Finish")
getStreamIds.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamIds)
waitForExpectations(timeout: 2)
guard let streamIds = getStreamIds.streamIds else {
XCTFail("\(FeedlyGetStreamIdsOperation.self) did not store the stream.")
return
}
XCTAssertEqual(streamIds.continuation, mockStreamIds.continuation)
XCTAssertEqual(streamIds.ids, mockStreamIds.ids)
}
func testGetStreamIdsFromJSON() {
let support = FeedlyTestSupport()
let (transport, caller) = support.makeMockNetworkStack()
let jsonName = "feedly_unreads_1000"
transport.testFiles["/v3/streams/ids"] = "\(jsonName).json"
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let getStreamIds = FeedlyGetStreamIdsOperation(account: account, resource: resource, service: caller, continuation: nil, newerThan: nil, unreadOnly: nil)
let completionExpectation = expectation(description: "Did Finish")
getStreamIds.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(getStreamIds)
waitForExpectations(timeout: 2)
guard let streamIds = getStreamIds.streamIds else {
return XCTFail("Expected to have a stream of identifiers.")
}
let streamIdsJSON = support.testJSON(named: jsonName) as! [String:Any]
let continuation = streamIdsJSON["continuation"] as! String
XCTAssertEqual(streamIds.continuation, continuation)
XCTAssertEqual(streamIds.ids, streamIdsJSON["ids"] as! [String])
}
}

View File

@ -0,0 +1,207 @@
//
// FeedlyMirrorCollectionsAsFoldersOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 22/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
class CollectionsProvider: FeedlyCollectionProviding {
var collections = [
FeedlyCollection(feeds: [], label: "One", id: "collections/1"),
FeedlyCollection(feeds: [], label: "Two", id: "collections/2")
]
}
func testAddsFolders() {
let provider = CollectionsProvider()
let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
mirrorOperation.completionBlock = {
completionExpectation.fulfill()
}
XCTAssertTrue(mirrorOperation.collectionsAndFolders.isEmpty)
XCTAssertTrue(mirrorOperation.feedsAndFolders.isEmpty)
OperationQueue.main.addOperation(mirrorOperation)
waitForExpectations(timeout: 2)
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.nameForDisplay })
let folderExternalIds = Set(folders.compactMap { $0.externalID })
let collectionLabels = Set(provider.collections.map { $0.label })
let collectionIds = Set(provider.collections.map { $0.id })
let missingNames = collectionLabels.subtracting(folderNames)
let missingIds = collectionIds.subtracting(folderExternalIds)
XCTAssertTrue(missingNames.isEmpty, "Collections with these labels have no corresponding folder.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids have no corresponding folder.")
XCTAssertEqual(mirrorOperation.collectionsAndFolders.count, provider.collections.count, "Mismatch between collections and folders.")
}
func testRemovesFolders() {
let provider = CollectionsProvider()
do {
let addFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
addFolders.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(addFolders)
waitForExpectations(timeout: 2)
}
// Now that the folders are added, remove them all.
provider.collections = []
let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
removeFolders.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(removeFolders)
waitForExpectations(timeout: 2)
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.nameForDisplay })
let folderExternalIds = Set(folders.compactMap { $0.externalID })
let collectionLabels = Set(provider.collections.map { $0.label })
let collectionIds = Set(provider.collections.map { $0.id })
let remainingNames = folderNames.subtracting(collectionLabels)
let remainingIds = folderExternalIds.subtracting(collectionIds)
XCTAssertTrue(remainingNames.isEmpty, "Folders with these names remain with no corresponding collection.")
XCTAssertTrue(remainingIds.isEmpty, "Folders with these ids remain with no corresponding collection.")
XCTAssertTrue(removeFolders.collectionsAndFolders.isEmpty)
XCTAssertTrue(removeFolders.feedsAndFolders.isEmpty)
}
class CollectionsAndFeedsProvider: FeedlyCollectionProviding {
var feedsForCollectionOne = [
FeedlyFeed(feedId: "feed/1", id: "feed/1", title: "Feed One", updated: nil, website: nil),
FeedlyFeed(feedId: "feed/2", id: "feed/2", title: "Feed Two", updated: nil, website: nil)
]
var feedsForCollectionTwo = [
FeedlyFeed(feedId: "feed/1", id: "feed/1", title: "Feed One", updated: nil, website: nil),
FeedlyFeed(feedId: "feed/3", id: "feed/3", title: "Feed Three", updated: nil, website: nil),
]
var collections: [FeedlyCollection] {
return [
FeedlyCollection(feeds: feedsForCollectionOne, label: "One", id: "collections/1"),
FeedlyCollection(feeds: feedsForCollectionTwo, label: "Two", id: "collections/2")
]
}
}
func testFeedMappedToFolders() {
let provider = CollectionsAndFeedsProvider()
let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
mirrorOperation.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(mirrorOperation)
waitForExpectations(timeout: 2)
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.nameForDisplay })
let folderExternalIds = Set(folders.compactMap { $0.externalID })
let collectionLabels = Set(provider.collections.map { $0.label })
let collectionIds = Set(provider.collections.map { $0.id })
let missingNames = collectionLabels.subtracting(folderNames)
let missingIds = collectionIds.subtracting(folderExternalIds)
XCTAssertTrue(missingNames.isEmpty, "Collections with these labels have no corresponding folder.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids have no corresponding folder.")
let collectionIdsAndFeedIds = provider.collections.map { collection -> [String:[String]] in
return [collection.id: collection.feeds.map { $0.id }.sorted(by: <)]
}
let folderIdsAndFeedIds = mirrorOperation.feedsAndFolders.compactMap { feeds, folder -> [String:[String]]? in
guard let id = folder.externalID else {
return nil
}
return [id: feeds.map { $0.id }.sorted(by: <)]
}
XCTAssertEqual(collectionIdsAndFeedIds, folderIdsAndFeedIds, "Did not map folders to feeds correctly.")
}
func testRemovingFolderRemovesFeeds() {
do {
let provider = CollectionsAndFeedsProvider()
let addFoldersAndFeeds = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addFoldersAndFeeds, log: support.log)
createFeeds.addDependency(addFoldersAndFeeds)
let completionExpectation = expectation(description: "Did Finish")
createFeeds.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperations([addFoldersAndFeeds, createFeeds], waitUntilFinished: false)
waitForExpectations(timeout: 2)
XCTAssertFalse(account.flattenedFeeds().isEmpty, "Expected account to have feeds.")
}
// Now that the folders are added, remove them all.
let provider = CollectionsProvider()
provider.collections = []
let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
removeFolders.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(removeFolders)
waitForExpectations(timeout: 2)
let feeds = account.flattenedFeeds()
XCTAssertTrue(feeds.isEmpty)
}
}

View File

@ -0,0 +1,146 @@
//
// FeedlyOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 21/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyOperationTests: XCTestCase {
enum TestOperationError: Error, Equatable {
case mockError
case anotherMockError
}
final class TestOperation: FeedlyOperation {
var didCallMainExpectation: XCTestExpectation?
var mockError: Error?
override func main() {
// Should always call on main thread.
XCTAssertTrue(Thread.isMainThread)
didCallMainExpectation?.fulfill()
if let error = mockError {
didFinish(error)
} else {
didFinish()
}
}
}
final class TestDelegate: FeedlyOperationDelegate {
var error: Error?
var didFailExpectation: XCTestExpectation?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
didFailExpectation?.fulfill()
self.error = error
}
}
func testDoesCallMain() {
let testOperation = TestOperation()
testOperation.didCallMainExpectation = expectation(description: "Did Call Main")
OperationQueue.main.addOperation(testOperation)
waitForExpectations(timeout: 2)
}
func testDoesFail() {
let testOperation = TestOperation()
testOperation.didCallMainExpectation = expectation(description: "Did Call Main")
testOperation.mockError = TestOperationError.mockError
let delegate = TestDelegate()
delegate.didFailExpectation = expectation(description: "Operation Failed As Expected")
testOperation.delegate = delegate
OperationQueue.main.addOperation(testOperation)
waitForExpectations(timeout: 2)
if let error = delegate.error as? TestOperationError {
XCTAssertEqual(error, TestOperationError.mockError)
} else {
XCTFail("Expected \(TestOperationError.self) but got \(String(describing: delegate.error)).")
}
}
func testOperationFlags() {
let testOperation = TestOperation()
testOperation.didCallMainExpectation = expectation(description: "Did Call Main")
let completionExpectation = expectation(description: "Operation Completed")
testOperation.completionBlock = {
completionExpectation.fulfill()
}
XCTAssertTrue(testOperation.isReady)
XCTAssertFalse(testOperation.isFinished)
XCTAssertFalse(testOperation.isExecuting)
XCTAssertFalse(testOperation.isCancelled)
OperationQueue.main.addOperation(testOperation)
waitForExpectations(timeout: 2)
XCTAssertTrue(testOperation.isReady)
XCTAssertTrue(testOperation.isFinished)
XCTAssertFalse(testOperation.isExecuting)
XCTAssertFalse(testOperation.isCancelled)
}
func testOperationCancellationFlags() {
let testOperation = TestOperation()
testOperation.didCallMainExpectation = expectation(description: "Did Call Main")
let completionExpectation = expectation(description: "Operation Completed")
testOperation.completionBlock = {
completionExpectation.fulfill()
}
XCTAssertTrue(testOperation.isReady)
XCTAssertFalse(testOperation.isFinished)
XCTAssertFalse(testOperation.isExecuting)
XCTAssertFalse(testOperation.isCancelled)
OperationQueue.main.addOperation(testOperation)
testOperation.cancel()
waitForExpectations(timeout: 2)
XCTAssertTrue(testOperation.isReady)
XCTAssertTrue(testOperation.isFinished)
XCTAssertFalse(testOperation.isExecuting)
XCTAssertTrue(testOperation.isCancelled)
}
func testDependency() {
let testOperation = TestOperation()
testOperation.didCallMainExpectation = expectation(description: "Did Call Main")
let dependencyExpectation = expectation(description: "Did Call Dependency")
let blockOperation = BlockOperation {
dependencyExpectation.fulfill()
}
blockOperation.addDependency(testOperation)
XCTAssertTrue(blockOperation.dependencies.contains(testOperation))
OperationQueue.main.addOperations([testOperation, blockOperation], waitUntilFinished: false)
waitForExpectations(timeout: 2)
}
}

View File

@ -0,0 +1,100 @@
//
// FeedlyOrganiseParsedItemsByFeedOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 24/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSParser
class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
struct TestParsedItemsProvider: FeedlyParsedItemProviding {
var resource: FeedlyResourceId
var parsedEntries: Set<ParsedItem>
}
func testNoEntries() {
let entries = support.makeParsedItemTestDataFor(numberOfFeeds: 0, numberOfItemsInFeeds: 0)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let parsedEntries = Set(entries.values.flatMap { $0 })
let provider = TestParsedItemsProvider(resource: resource, parsedEntries: parsedEntries)
let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
organise.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(organise)
waitForExpectations(timeout: 2)
let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId
XCTAssertEqual(itemsAndFeedIds, entries)
XCTAssertEqual(resource.id, organise.providerName)
}
func testGroupsOneEntryByFeedId() {
let entries = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let parsedEntries = Set(entries.values.flatMap { $0 })
let provider = TestParsedItemsProvider(resource: resource, parsedEntries: parsedEntries)
let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
organise.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(organise)
waitForExpectations(timeout: 2)
let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId
XCTAssertEqual(itemsAndFeedIds, entries)
XCTAssertEqual(resource.id, organise.providerName)
}
func testGroupsManyEntriesByFeedId() {
let entries = support.makeParsedItemTestDataFor(numberOfFeeds: 100, numberOfItemsInFeeds: 100)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let parsedEntries = Set(entries.values.flatMap { $0 })
let provider = TestParsedItemsProvider(resource: resource, parsedEntries: parsedEntries)
let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
organise.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(organise)
waitForExpectations(timeout: 2)
let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId
XCTAssertEqual(itemsAndFeedIds, entries)
XCTAssertEqual(resource.id, organise.providerName)
}
}

View File

@ -0,0 +1,401 @@
//
// FeedlySendArticleStatusesOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 25/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import SyncDatabase
import Articles
class FeedlySendArticleStatusesOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
private var container: FeedlyTestSupport.TestDatabaseContainer!
override func setUp() {
super.setUp()
account = support.makeTestAccount()
container = support.makeTestDatabaseContainer()
}
override func tearDown() {
container = nil
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testSendEmpty() {
let service = TestMarkArticlesService()
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
}
func testSendUnreadSuccess() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .success(())
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .unread)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
}
func testSendUnreadFailure() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .failure(URLError(.timedOut))
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .unread)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count)
}
func testSendReadSuccess() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .success(())
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .read)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
}
func testSendReadFailure() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .failure(URLError(.timedOut))
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .read)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count)
}
func testSendStarredSuccess() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .success(())
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .saved)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
}
func testSendStarredFailure() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .failure(URLError(.timedOut))
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .saved)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count)
}
func testSendUnstarredSuccess() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .success(())
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .unsaved)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
}
func testSendUnstarredFailure() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .failure(URLError(.timedOut))
service.parameterTester = { serviceArticleIds, action in
XCTAssertEqual(serviceArticleIds, articleIds)
XCTAssertEqual(action, .unsaved)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count)
}
func testSendAllSuccess() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let keys = [ArticleStatus.Key.read, .starred]
let flags = [true, false]
let statuses = articleIds.map { articleId -> SyncStatus in
let key = keys.randomElement()!
let flag = flags.randomElement()!
let status = SyncStatus(articleID: articleId, key: key, flag: flag)
return status
}
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .success(())
service.parameterTester = { serviceArticleIds, action in
let syncStatuses: [SyncStatus]
switch action {
case .read:
syncStatuses = statuses.filter { $0.key == .read && $0.flag == true }
case .unread:
syncStatuses = statuses.filter { $0.key == .read && $0.flag == false }
case .saved:
syncStatuses = statuses.filter { $0.key == .starred && $0.flag == true }
case .unsaved:
syncStatuses = statuses.filter { $0.key == .starred && $0.flag == false }
}
let expectedArticleIds = Set(syncStatuses.map { $0.articleID })
XCTAssertEqual(serviceArticleIds, expectedArticleIds)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
}
func testSendAllFailure() {
let articleIds = Set((0..<100).map { "feed/0/article/\($0)" })
let keys = [ArticleStatus.Key.read, .starred]
let flags = [true, false]
let statuses = articleIds.map { articleId -> SyncStatus in
let key = keys.randomElement()!
let flag = flags.randomElement()!
let status = SyncStatus(articleID: articleId, key: key, flag: flag)
return status
}
let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) {
insertExpectation.fulfill()
}
waitForExpectations(timeout: 2)
let service = TestMarkArticlesService()
service.mockResult = .failure(URLError(.timedOut))
service.parameterTester = { serviceArticleIds, action in
let syncStatuses: [SyncStatus]
switch action {
case .read:
syncStatuses = statuses.filter { $0.key == .read && $0.flag == true }
case .unread:
syncStatuses = statuses.filter { $0.key == .read && $0.flag == false }
case .saved:
syncStatuses = statuses.filter { $0.key == .starred && $0.flag == true }
case .unsaved:
syncStatuses = statuses.filter { $0.key == .starred && $0.flag == false }
}
let expectedArticleIds = Set(syncStatuses.map { $0.articleID })
XCTAssertEqual(serviceArticleIds, expectedArticleIds)
}
let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
let didFinishExpectation = expectation(description: "Did Finish")
send.completionBlock = {
didFinishExpectation.fulfill()
}
OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count)
}
}

View File

@ -0,0 +1,398 @@
//
// FeedlySetStarredArticlesOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 25/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSParser
class FeedlySetStarredArticlesOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
// MARK: - Ensuring Unread Status By Id
struct TestStarredArticleProvider: FeedlyStarredEntryIdProviding {
var entryIds: Set<String>
}
func testEmptyArticleIds() {
let testIds = Set<String>()
let provider = TestStarredArticleProvider(entryIds: testIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertTrue(accountArticlesIDs.isEmpty)
XCTAssertEqual(accountArticlesIDs, testIds)
}
func testSetOneArticleIdStarred() {
let testIds = Set<String>(["feed/0/article/0"])
let provider = TestStarredArticleProvider(entryIds: testIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
}
func testSetManyArticleIdsStarred() {
let testIds = Set<String>((0..<10_000).map { "feed/0/article/\($0)" })
let provider = TestStarredArticleProvider(entryIds: testIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
}
func testSetSomeArticleIdsUnstarred() {
let initialStarredIds = Set<String>((0..<1000).map { "feed/0/article/\($0)" })
do {
let provider = TestStarredArticleProvider(entryIds: initialStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish Setting Initial Unreads")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
}
let remainingStarredIds = Set(initialStarredIds.enumerated().filter { $0.offset % 2 > 0 }.map { $0.element })
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let remainingAccountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
}
func testSetAllArticleIdsUnstarred() {
let initialStarredIds = Set<String>((0..<1000).map { "feed/0/article/\($0)" })
do {
let provider = TestStarredArticleProvider(entryIds: initialStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish Setting Initial Unreads")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
}
let remainingStarredIds = Set<String>()
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let remainingAccountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
}
// MARK: - Updating Article Unread Status
struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding {
var providerName: String
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>]
}
func testSetAllArticlesStarred() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let remainingStarredIds = Set(testItems.compactMap { $0.syncServiceID })
XCTAssertEqual(testItems.count, remainingStarredIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(account
.fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
}
func testSetManyArticlesUnread() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let unreadItems = testItems
.enumerated()
.filter { $0.offset % 2 > 0 }
.map { $0.element }
let remainingStarredIds = Set(unreadItems.compactMap { $0.syncServiceID })
XCTAssertEqual(unreadItems.count, remainingStarredIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(account
.fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
}
func testSetOneArticleUnread() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
// Since the test data is completely under the developer's control, not having at least one can be a programmer error.
let remainingStarredIds = Set([testItems.compactMap { $0.syncServiceID }.first!])
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(account
.fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
}
func testSetNoArticlesRead() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let remainingStarredIds = Set<String>()
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(account
.fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
}
func testSetAllArticlesAndArticleIdsWithSomeArticlesIngested() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
let someItemsAndFeeds = Dictionary(uniqueKeysWithValues: testItemsAndFeeds.enumerated().filter { $0.offset % 2 > 0 }.map { $0.element })
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: someItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let remainingStarredIds = Set(testItems.compactMap { $0.syncServiceID })
XCTAssertEqual(testItems.count, remainingStarredIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestStarredArticleProvider(entryIds: remainingStarredIds)
let setStarred = FeedlySetStarredArticlesOperation(account: account, allStarredEntryIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setStarred)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchStarredArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
let idsOfStarredArticles = Set(account
.fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles))
.filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles)
}
}

View File

@ -0,0 +1,398 @@
//
// FeedlySetUnreadArticlesOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 24/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSParser
class FeedlySetUnreadArticlesOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
// MARK: - Ensuring Unread Status By Id
struct TestUnreadArticleIdProvider: FeedlyUnreadEntryIdProviding {
var entryIds: Set<String>
}
func testEmptyArticleIds() {
let testIds = Set<String>()
let provider = TestUnreadArticleIdProvider(entryIds: testIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertTrue(accountArticlesIDs.isEmpty)
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
}
func testSetOneArticleIdUnread() {
let testIds = Set<String>(["feed/0/article/0"])
let provider = TestUnreadArticleIdProvider(entryIds: testIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
}
func testSetManyArticleIdsUnread() {
let testIds = Set<String>((0..<10_000).map { "feed/0/article/\($0)" })
let provider = TestUnreadArticleIdProvider(entryIds: testIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
}
func testSetSomeArticleIdsRead() {
let initialUnreadIds = Set<String>((0..<1000).map { "feed/0/article/\($0)" })
do {
let provider = TestUnreadArticleIdProvider(entryIds: initialUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish Setting Initial Unreads")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
}
let remainingUnreadIds = Set(initialUnreadIds.enumerated().filter { $0.offset % 2 > 0 }.map { $0.element })
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let remainingAccountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
}
func testSetAllArticleIdsRead() {
let initialUnreadIds = Set<String>((0..<1000).map { "feed/0/article/\($0)" })
do {
let provider = TestUnreadArticleIdProvider(entryIds: initialUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish Setting Initial Unreads")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
}
let remainingUnreadIds = Set<String>()
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let remainingAccountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
}
// MARK: - Updating Article Unread Status
struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding {
var providerName: String
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>]
}
func testSetAllArticlesUnread() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let remainingUnreadIds = Set(testItems.compactMap { $0.syncServiceID })
XCTAssertEqual(testItems.count, remainingUnreadIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(account
.fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
}
func testSetManyArticlesUnread() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let unreadItems = testItems
.enumerated()
.filter { $0.offset % 2 > 0 }
.map { $0.element }
let remainingUnreadIds = Set(unreadItems.compactMap { $0.syncServiceID })
XCTAssertEqual(unreadItems.count, remainingUnreadIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(account
.fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
}
func testSetOneArticleUnread() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
// Since the test data is completely under the developer's control, not having at least one can be a programmer error.
let remainingUnreadIds = Set([testItems.compactMap { $0.syncServiceID }.first!])
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(account
.fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
}
func testSetNoArticlesRead() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let remainingUnreadIds = Set<String>()
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(account
.fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
}
func testSetAllArticlesAndArticleIdsWithSomeArticlesIngested() {
let testItemsAndFeeds = support.makeParsedItemTestDataFor(numberOfFeeds: 5, numberOfItemsInFeeds: 100)
let someItemsAndFeeds = Dictionary(uniqueKeysWithValues: testItemsAndFeeds.enumerated().filter { $0.offset % 2 > 0 }.map { $0.element })
do {
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: someItemsAndFeeds)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
}
let testItems = Set(testItemsAndFeeds.flatMap { $0.value })
let remainingUnreadIds = Set(testItems.compactMap { $0.syncServiceID })
XCTAssertEqual(testItems.count, remainingUnreadIds.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let provider = TestUnreadArticleIdProvider(entryIds: remainingUnreadIds)
let setUnread = FeedlySetUnreadArticlesOperation(account: account, allUnreadIdsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
setUnread.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(setUnread)
waitForExpectations(timeout: 2)
let accountArticlesIDs = account.fetchUnreadArticleIDs()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
let idsOfUnreadArticles = Set(account
.fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles))
.filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles)
}
}

View File

@ -0,0 +1,70 @@
//
// FeedlySyncAllMockResponseProvider.swift
// AccountTests
//
// Created by Kiel Gillard on 1/11/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
class FeedlyMockResponseProvider: TestTransportMockResponseProviding {
let subdirectory: String
init(findingMocksIn subdirectory: String) {
self.subdirectory = subdirectory
}
func mockResponseFileUrl(for components: URLComponents) -> URL? {
let bundle = Bundle(for: FeedlyMockResponseProvider.self)
// Match request for collections to build a list of folders.
if components.path.contains("v3/collections") {
return bundle.url(forResource: "collections", withExtension: "json", subdirectory: subdirectory)
}
guard let queryItems = components.queryItems else {
return nil
}
// Match requests for starred articles from global.saved.
if components.path.contains("streams/contents") &&
queryItems.contains(where: { ($0.value ?? "").contains("global.saved") }) {
return bundle.url(forResource: "starred", withExtension: "json", subdirectory: subdirectory)
}
let continuation = queryItems.first(where: { $0.name.contains("continuation") })?.value
// Match requests for unread article ids.
if components.path.contains("streams/ids") && queryItems.contains(where: { $0.name.contains("unreadOnly") }) {
// if there is a continuation, return the page for it
if let continuation = continuation, let data = continuation.data(using: .utf8) {
let base64 = data.base64EncodedString() // at least base64 can be used as a path component.
return bundle.url(forResource: "unreadIds@\(base64)", withExtension: "json", subdirectory: subdirectory)
} else {
// return first page
return bundle.url(forResource: "unreadIds", withExtension: "json", subdirectory: subdirectory)
}
}
// Match requests for the contents of global.all.
if components.path.contains("streams/contents") &&
queryItems.contains(where: { ($0.value ?? "").contains("global.all") }){
// if there is a continuation, return the page for it
if let continuation = continuation, let data = continuation.data(using: .utf8) {
let base64 = data.base64EncodedString() // at least base64 can be used as a path component.
return bundle.url(forResource: "global.all@\(base64)", withExtension: "json", subdirectory: subdirectory)
} else {
// return first page
return bundle.url(forResource: "global.all", withExtension: "json", subdirectory: subdirectory)
}
}
return nil
}
}

View File

@ -0,0 +1,266 @@
//
// FeedlySyncAllOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 30/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSWeb
class FeedlySyncAllOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testCancel() {
let markArticlesService = TestMarkArticlesService()
markArticlesService.didMarkExpectation = expectation(description: "Set Article Statuses")
markArticlesService.didMarkExpectation?.isInverted = true
let getStreamIdsService = TestGetStreamIdsService()
getStreamIdsService.getStreamIdsExpectation = expectation(description: "Get Unread Article Identifiers")
getStreamIdsService.getStreamIdsExpectation?.isInverted = true
let getCollectionsService = TestGetCollectionsService()
getCollectionsService.getCollectionsExpectation = expectation(description: "Get User's Collections")
getCollectionsService.getCollectionsExpectation?.isInverted = true
let getGlobalStreamContents = TestGetStreamContentsService()
getGlobalStreamContents.getStreamContentsExpectation = expectation(description: "Get Contents of global.all")
getGlobalStreamContents.getStreamContentsExpectation?.isInverted = true
let getStarredContents = TestGetStreamContentsService()
getStarredContents.getStreamContentsExpectation = expectation(description: "Get Contents of global.saved")
getStarredContents.getStreamContentsExpectation?.isInverted = true
let container = support.makeTestDatabaseContainer()
let syncAll = FeedlySyncAllOperation(account: account,
credentials: support.credentials,
lastSuccessfulFetchStartDate: nil,
markArticlesService: markArticlesService,
getUnreadService: getStreamIdsService,
getCollectionsService: getCollectionsService,
getStreamContentsService: getGlobalStreamContents,
getStarredArticlesService: getStarredContents,
database: container.database,
log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
syncAll.completionBlock = {
completionExpectation.fulfill()
}
let syncCompletionExpectation = expectation(description: "Did Finish Sync")
syncAll.syncCompletionHandler = { result in
switch result {
case .success:
XCTFail("Expected failure.")
case .failure:
break
}
syncCompletionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncAll)
syncAll.cancel()
waitForExpectations(timeout: 2)
XCTAssertNil(syncAll.syncCompletionHandler, "Expected completion handler to be destroyed after completion.")
}
private var transport = TestTransport()
lazy var caller: FeedlyAPICaller = {
let caller = FeedlyAPICaller(transport: transport, api: .sandbox)
caller.credentials = support.credentials
return caller
}()
func testSyncing() {
performInitialSync()
verifyInitialSync()
performChangeStatuses()
verifyChangeStatuses()
performChangeStatusesAgain()
verifyChangeStatusesAgain()
performAddFeedsAndFolders()
verifyAddFeedsAndFolders()
}
// MARK: 1 - Initial Sync
private func loadMockData(inSubdirectoryNamed subdirectory: String) {
let provider = FeedlyMockResponseProvider(findingMocksIn: subdirectory)
transport.mockResponseFileUrlProvider = provider
// lastSuccessfulFetchStartDate does not matter for the test, content will always be the same.
// It is tested in `FeedlyGetStreamContentsOperationTests`.
let syncAll = FeedlySyncAllOperation(account: account,
credentials: support.credentials,
caller: caller,
database: databaseContainer.database,
lastSuccessfulFetchStartDate: nil,
log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
syncAll.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncAll)
waitForExpectations(timeout: 2)
}
func performInitialSync() {
loadMockData(inSubdirectoryNamed: "feedly-1-initial")
}
func verifyInitialSync() {
let subdirectory = "feedly-1-initial"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all@MTZkOTdkZWQ1NzM6NTE2OjUzYjgyNmEy", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTRhOTNhZTQ6MzExOjUzYjgyNmEy", subdirectory: subdirectory)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
}
// MARK: 2 - Change Statuses
func performChangeStatuses() {
loadMockData(inSubdirectoryNamed: "feedly-2-changestatuses")
}
func verifyChangeStatuses() {
let subdirectory = "feedly-2-changestatuses"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTJkNjIwM2Q6MTEzYjpkNDUwNjA3MQ==", subdirectory: subdirectory)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
}
// MARK: 3 - Change Statuses Again
func performChangeStatusesAgain() {
loadMockData(inSubdirectoryNamed: "feedly-3-changestatusesagain")
}
func verifyChangeStatusesAgain() {
let subdirectory = "feedly-3-changestatusesagain"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YyOmQ0NTA2MDcx", subdirectory: subdirectory)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
}
// MARK: 4 - Add Feeds and Folders
func performAddFeedsAndFolders() {
loadMockData(inSubdirectoryNamed: "feedly-4-addfeedsandfolders")
}
func verifyAddFeedsAndFolders() {
let subdirectory = "feedly-4-addfeedsandfolders"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTE3YTRlMzQ6YWZjOmQ0NTA2MDcx", subdirectory: subdirectory)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
}
// MARK: 5 - Remove Feeds and Folders
func performRemoveFeedsAndFolders() {
loadMockData(inSubdirectoryNamed: "feedly-5-removefeedsandfolders")
}
func verifyRemoveFeedsAndFolders() {
let subdirectory = "feedly-5-removefeedsandfolders"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YxOmQ0NTA2MDcx", subdirectory: subdirectory)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
}
// MARK: Downloading Test Data
var lastSuccessfulFetchStartDate: Date?
lazy var databaseContainer: FeedlyTestSupport.TestDatabaseContainer = {
return support.makeTestDatabaseContainer()
}()
func downloadTestData() {
let caller = FeedlyAPICaller(transport: URLSession.webserviceTransport(), api: .sandbox)
let credentials = Credentials(type: .oauthAccessToken, username: "<#USERNAME#>", secret: "<#SECRET#>")
caller.credentials = credentials
let syncAll = FeedlySyncAllOperation(account: account, credentials: credentials, caller: caller, database: databaseContainer.database, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
syncAll.completionBlock = {
completionExpectation.fulfill()
}
lastSuccessfulFetchStartDate = Date()
OperationQueue.main.addOperation(syncAll)
waitForExpectations(timeout: 60)
}
// Prefix with "test" to manually run this particular function, e.g.: func test_getTestData()
func getTestData() {
// Add a breakpoint on the `print` statements and start a proxy server on your Mac.
// 1. In Feedly sandbox, perform the actions implied by the string in the print statement.
// 2. In the proxy server app, such as Charles, clear requests and responses and filter by "sandbox".
// 3. In Xcode, hit continue in the Debugger so the test requests the data.
// 4. Save the responses captured by the proxy.
print("Prepare for initial sync.")
downloadTestData()
assert(lastSuccessfulFetchStartDate != nil)
print("Read/unread, star and unstar some articles.")
downloadTestData()
print("Read/unread, star and unstar some articles again.")
downloadTestData()
print("Add Feeds and Folders.")
downloadTestData()
print("Rename and Remove Feeds and Folders.")
downloadTestData()
}
}

View File

@ -0,0 +1,160 @@
//
// FeedlySyncStarredArticlesOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 28/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlySyncStarredArticlesOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testIngestsOnePageSuccess() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let items = service.makeMockFeedlyEntryItem()
service.mockResult = .success(FeedlyStream(id: resource.id, updated: nil, continuation: nil, items: items))
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertNil(continuation)
XCTAssertNil(serviceUnreadOnly)
}
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, resource: resource, service: service, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStarred)
waitForExpectations(timeout: 2)
let expectedArticleIds = Set(items.map { $0.id })
let starredArticleIds = account.fetchStarredArticleIDs()
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.")
// Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to.
let expectedArticles = account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
let starredArticles = account.fetchArticles(.articleIDs(starredArticleIds))
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
let missingArticles = expectedArticles.subtracting(starredArticles)
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
XCTAssertEqual(expectedArticles, starredArticles)
}
func testIngestsOnePageFailure() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
service.mockResult = .failure(URLError(.timedOut))
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertNil(continuation)
XCTAssertNil(serviceUnreadOnly)
}
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, resource: resource, service: service, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStarred)
waitForExpectations(timeout: 2)
let starredArticleIds = account.fetchStarredArticleIDs()
XCTAssertTrue(starredArticleIds.isEmpty)
}
func testIngestsManyPagesSuccess() {
let service = TestGetPagedStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let continuations = (1...10).map { "\($0)" }
service.addAtLeastOnePage(for: resource, continuations: continuations, numberOfEntriesPerPage: 10)
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1 + continuations.count
var remainingContinuations = Set(continuations)
let getStreamPageExpectation = expectation(description: "Did Request Page")
getStreamPageExpectation.expectedFulfillmentCount = 1 + continuations.count
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertNil(serviceUnreadOnly)
if let continuation = continuation {
XCTAssertTrue(remainingContinuations.contains(continuation))
remainingContinuations.remove(continuation)
}
getStreamPageExpectation.fulfill()
}
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, resource: resource, service: service, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStarred.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStarred)
waitForExpectations(timeout: 2)
// Find articles inserted.
let expectedArticleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
let starredArticleIds = account.fetchStarredArticleIDs()
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.")
// Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to.
let expectedArticles = account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
let starredArticles = account.fetchArticles(.articleIDs(starredArticleIds))
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
let missingArticles = expectedArticles.subtracting(starredArticles)
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
XCTAssertEqual(expectedArticles, starredArticles)
}
}

View File

@ -0,0 +1,138 @@
//
// FeedlySyncStreamContentsOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 26/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlySyncStreamContentsOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testIngestsOnePageSuccess() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
let items = service.makeMockFeedlyEntryItem()
service.mockResult = .success(FeedlyStream(id: resource.id, updated: nil, continuation: nil, items: items))
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertEqual(serviceNewerThan, newerThan)
XCTAssertNil(continuation)
XCTAssertNil(serviceUnreadOnly)
}
let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStreamContents)
waitForExpectations(timeout: 2)
let expectedArticleIds = Set(items.map { $0.id })
let expectedArticles = account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
}
func testIngestsOnePageFailure() {
let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
service.mockResult = .failure(URLError(.timedOut))
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertEqual(serviceNewerThan, newerThan)
XCTAssertNil(continuation)
XCTAssertNil(serviceUnreadOnly)
}
let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStreamContents)
waitForExpectations(timeout: 2)
}
func testIngestsManyPagesSuccess() {
let service = TestGetPagedStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
let continuations = (1...10).map { "\($0)" }
service.addAtLeastOnePage(for: resource, continuations: continuations, numberOfEntriesPerPage: 1000)
let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamContentsExpectation.expectedFulfillmentCount = 1 + continuations.count
var remainingContinuations = Set(continuations)
let getStreamPageExpectation = expectation(description: "Did Request Page")
getStreamPageExpectation.expectedFulfillmentCount = 1 + continuations.count
service.getStreamContentsExpectation = getStreamContentsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertEqual(serviceNewerThan, newerThan)
XCTAssertNil(serviceUnreadOnly)
if let continuation = continuation {
XCTAssertTrue(remainingContinuations.contains(continuation))
remainingContinuations.remove(continuation)
}
getStreamPageExpectation.fulfill()
}
let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncStreamContents.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncStreamContents)
waitForExpectations(timeout: 30)
// Find articles inserted.
let articleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
let articles = account.fetchArticles(.articleIDs(articleIds))
XCTAssertEqual(articleIds.count, articles.count)
}
}

View File

@ -0,0 +1,140 @@
//
// FeedlySyncUnreadStatusesOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 29/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
func testIngestsOnePageSuccess() {
let service = TestGetStreamIdsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let ids = [UUID().uuidString]
service.mockResult = .success(FeedlyStreamIds(continuation: nil, ids: ids))
let getStreamIdsExpectation = expectation(description: "Did Get Page of Stream Ids")
getStreamIdsExpectation.expectedFulfillmentCount = 1
service.getStreamIdsExpectation = getStreamIdsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertNil(continuation)
XCTAssertEqual(serviceUnreadOnly, true)
}
let syncUnreads = FeedlySyncUnreadStatusesOperation(account: account, resource: resource, service: service, newerThan: nil, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncUnreads.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncUnreads)
waitForExpectations(timeout: 2)
let expectedArticleIds = Set(ids)
let unreadArticleIds = account.fetchUnreadArticleIDs()
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
}
func testIngestsOnePageFailure() {
let service = TestGetStreamIdsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
service.mockResult = .failure(URLError(.timedOut))
let getStreamIdsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamIdsExpectation.expectedFulfillmentCount = 1
service.getStreamIdsExpectation = getStreamIdsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertNil(continuation)
XCTAssertEqual(serviceUnreadOnly, true)
}
let syncUnreads = FeedlySyncUnreadStatusesOperation(account: account, resource: resource, service: service, newerThan: nil, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncUnreads.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncUnreads)
waitForExpectations(timeout: 2)
let unreadArticleIds = account.fetchUnreadArticleIDs()
XCTAssertTrue(unreadArticleIds.isEmpty)
}
func testIngestsManyPagesSuccess() {
let service = TestGetPagedStreamIdsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let continuations = (1...10).map { "\($0)" }
service.addAtLeastOnePage(for: resource, continuations: continuations, numberOfEntriesPerPage: 1000)
let getStreamIdsExpectation = expectation(description: "Did Get Page of Stream Contents")
getStreamIdsExpectation.expectedFulfillmentCount = 1 + continuations.count
var remainingContinuations = Set(continuations)
let getStreamPageExpectation = expectation(description: "Did Request Page")
getStreamPageExpectation.expectedFulfillmentCount = 1 + continuations.count
service.getStreamIdsExpectation = getStreamIdsExpectation
service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
XCTAssertEqual(serviceResource.id, resource.id)
XCTAssertNil(serviceNewerThan)
XCTAssertEqual(serviceUnreadOnly, true)
if let continuation = continuation {
XCTAssertTrue(remainingContinuations.contains(continuation))
remainingContinuations.remove(continuation)
}
getStreamPageExpectation.fulfill()
}
let syncUnreads = FeedlySyncUnreadStatusesOperation(account: account, resource: resource, service: service, newerThan: nil, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
syncUnreads.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(syncUnreads)
waitForExpectations(timeout: 2)
// Find statuses inserted.
let expectedArticleIds = Set(service.pages.values.map { $0.ids }.flatMap { $0 })
let unreadArticleIds = account.fetchUnreadArticleIDs()
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
}
}

View File

@ -0,0 +1,275 @@
//
// FeedlyTestSupport.swift
// AccountTests
//
// Created by Kiel Gillard on 22/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
import RSParser
@testable import Account
import os.log
import SyncDatabase
struct FeedlyTestSupport {
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "FeedlyTests")
var credentials = Credentials(type: .oauthAccessToken, username: "Test", secret: "t3st")
var transport = TestTransport()
func makeMockNetworkStack() -> (TestTransport, FeedlyAPICaller) {
let caller = FeedlyAPICaller(transport: transport, api: .sandbox)
caller.credentials = credentials
return (transport, caller)
}
func makeTestAccount() -> Account {
let manager = TestAccountManager()
let account = manager.createAccount(type: .feedly, transport: transport)
do {
try account.storeCredentials(credentials)
} catch {
XCTFail("Unable to register mock credentials because \(error)")
}
return account
}
func makeTestDatabaseContainer() -> TestDatabaseContainer {
return TestDatabaseContainer()
}
class TestDatabaseContainer {
private let path: String
private(set) var database: SyncDatabase!
init() {
let dataFolder = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
path = dataFolder.appendingPathComponent("\(UUID().uuidString)-Sync.sqlite3").path
database = SyncDatabase(databaseFilePath: path)
}
deinit {
// We should close the database before removing the database.
database = nil
do {
try FileManager.default.removeItem(atPath: path)
print("Removed database at \(path)")
} catch {
print("Unable to remove database owned by \(self) because \(error).")
}
}
}
func destroy(_ testAccount: Account) {
do {
try testAccount.removeCredentials(type: .oauthAccessToken)
} catch {
XCTFail("Unable to clean up mock credentials because \(error)")
}
let manager = TestAccountManager()
manager.deleteAccount(testAccount)
}
func testJSON(named: String, subdirectory: String? = nil) -> Any {
let bundle = Bundle(for: TestTransport.self)
let url = bundle.url(forResource: named, withExtension: "json", subdirectory: subdirectory)!
let data = try! Data(contentsOf: url)
let json = try! JSONSerialization.jsonObject(with: data)
return json
}
func checkFoldersAndFeeds(in account: Account, againstCollectionsAndFeedsInJSONNamed name: String, subdirectory: String? = nil) {
let collections = testJSON(named: name, subdirectory: subdirectory) as! [[String:Any]]
let collectionNames = Set(collections.map { $0["label"] as! String })
let collectionIds = Set(collections.map { $0["id"] as! String })
let folders = account.folders ?? Set()
let folderNames = Set(folders.compactMap { $0.name })
let folderIds = Set(folders.compactMap { $0.externalID })
let missingNames = collectionNames.subtracting(folderNames)
let missingIds = collectionIds.subtracting(folderIds)
XCTAssertEqual(folders.count, collections.count, "Mismatch between collections and folders.")
XCTAssertTrue(missingNames.isEmpty, "Collections with these names did not have a corresponding folder with the same name.")
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids did not have a corresponding folder with the same id.")
for collection in collections {
checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
}
}
func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONNamed name: String) {
let collection = testJSON(named: name) as! [String:Any]
checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
}
func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONPayload collection: [String: Any]) {
let label = collection["label"] as! String
guard let folder = account.existingFolder(with: label) else {
// due to a previous test failure?
XCTFail("Could not find the \"\(label)\" folder.")
return
}
let collectionFeeds = collection["feeds"] as! [[String: Any]]
let folderFeeds = folder.topLevelFeeds
XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
let folderFeedIds = Set(folderFeeds.map { $0.feedID })
let missingFeedIds = collectionFeedIds.subtracting(folderFeedIds)
XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
}
func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) {
let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
}
func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) {
checkArticles(in: account, correspondToStreamItemsIn: stream)
}
private struct ArticleItem {
var id: String
var feedId: String
var content: String
var JSON: [String: Any]
var unread: Bool
/// Convoluted external URL logic "documented" here:
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
var externalUrl: String? {
return ((JSON["canonical"] as? [[String: Any]]) ?? (JSON["alternate"] as? [[String: Any]]))?.compactMap { link -> String? in
let href = link["href"] as? String
if let type = link["type"] as? String {
if type == "text/html" {
return href
}
return nil
}
return href
}.first
}
init(item: [String: Any]) {
self.JSON = item
self.id = item["id"] as! String
let origin = item["origin"] as! [String: Any]
self.feedId = origin["streamId"] as! String
let content = item["content"] as? [String: Any]
let summary = item["summary"] as? [String: Any]
self.content = ((content ?? summary)?["content"] as? String) ?? ""
self.unread = item["unread"] as! Bool
}
}
/// Awkwardly titled to make it clear the JSON given is from a stream response.
func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) {
let items = stream["items"] as! [[String: Any]]
let articleItems = items.map { ArticleItem(item: $0) }
let itemIds = Set(articleItems.map { $0.id })
let articles = testAccount.fetchArticles(.articleIDs(itemIds))
let articleIds = Set(articles.map { $0.articleID })
let missing = itemIds.subtracting(articleIds)
XCTAssertEqual(items.count, articles.count)
XCTAssertTrue(missing.isEmpty, "Items with these ids did not have a corresponding article with the same id.")
for article in articles {
for item in articleItems where item.id == article.articleID {
XCTAssertEqual(article.uniqueID, item.id)
XCTAssertEqual(article.contentHTML, item.content)
XCTAssertEqual(article.feedID, item.feedId)
XCTAssertEqual(article.externalURL, item.externalUrl)
}
}
}
func checkUnreadStatuses(in account: Account, againstIdsInStreamInJSONNamed name: String, subdirectory: String? = nil) {
let streamIds = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
checkUnreadStatuses(in: account, correspondToIdsInJSONPayload: streamIds)
}
func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any]) {
let ids = Set(streamIds["ids"] as! [String])
let articleIds = testAccount.fetchUnreadArticleIDs()
// Unread statuses can be paged from Feedly.
// Instead of joining test data, the best we can do is
// make sure that these ids are marked as unread (a subset of the total).
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.")
}
func checkStarredStatuses(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) {
let streamIds = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
checkStarredStatuses(in: account, correspondToStreamItemsIn: streamIds)
}
func checkStarredStatuses(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) {
let items = stream["items"] as! [[String: Any]]
let ids = Set(items.map { $0["id"] as! String })
let articleIds = testAccount.fetchStarredArticleIDs()
// Starred articles can be paged from Feedly.
// Instead of joining test data, the best we can do is
// make sure that these articles are marked as starred (a subset of the total).
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.")
}
func check(_ entries: [FeedlyEntry], correspondToStreamItemsIn stream: [String: Any]) {
let items = stream["items"] as! [[String: Any]]
let itemIds = Set(items.map { $0["id"] as! String })
let articleIds = Set(entries.map { $0.id })
let missing = itemIds.subtracting(articleIds)
XCTAssertEqual(items.count, entries.count)
XCTAssertTrue(missing.isEmpty, "Failed to create \(FeedlyEntry.self) values from objects in the JSON with these ids.")
}
func makeParsedItemTestDataFor(numberOfFeeds: Int, numberOfItemsInFeeds: Int) -> [String: Set<ParsedItem>] {
let ids = (0..<numberOfFeeds).map { "feed/\($0)" }
let feedIdsAndItemCounts = ids.map { ($0, numberOfItemsInFeeds) }
let entries = feedIdsAndItemCounts.map { (feedId, count) -> (String, [Int]) in
return (feedId, (0..<count).map { $0 })
}.map { pair -> (String, Set<ParsedItem>) in
let items = pair.1.map { index -> ParsedItem in
ParsedItem(syncServiceID: "\(pair.0)/articles/\(index)",
uniqueID: UUID().uuidString,
feedURL: pair.0,
url: "http://localhost/",
externalURL: "http://localhost/\(pair.0)/articles/\(index).html",
title: "Title\(index)",
contentHTML: "Content \(index) HTML.",
contentText: "Content \(index) Text",
summary: nil,
imageURL: nil,
bannerImageURL: nil,
datePublished: nil,
dateModified: nil,
authors: nil,
tags: nil,
attachments: nil)
}
return (pair.0, Set(items))
}.reduce([String: Set<ParsedItem>](minimumCapacity: feedIdsAndItemCounts.count)) { (dict, pair) in
var mutant = dict
mutant[pair.0] = pair.1
return mutant
}
return entries
}
}

View File

@ -0,0 +1,140 @@
//
// FeedlyUpdateAccountFeedsWithItemsOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 24/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSParser
class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
private var account: Account!
private let support = FeedlyTestSupport()
override func setUp() {
super.setUp()
account = support.makeTestAccount()
}
override func tearDown() {
if let account = account {
support.destroy(account)
}
super.tearDown()
}
struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding {
var providerName: String
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>]
}
func testUpdateAccountWithEmptyItems() {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 0, numberOfItemsInFeeds: 0)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
let entries = testItems.flatMap { $0.value }
let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.isEmpty)
}
func testUpdateAccountWithOneItem() {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 2)
let entries = testItems.flatMap { $0.value }
let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.count == entries.count)
let accountArticleIds = Set(accountArticles.map { $0.articleID })
let missingIds = articleIds.subtracting(accountArticleIds)
XCTAssertTrue(missingIds.isEmpty)
}
func testUpdateAccountWithManyItems() {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 100, numberOfItemsInFeeds: 100)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
waitForExpectations(timeout: 10) // 10,000 articles takes ~ three seconds for me.
let entries = testItems.flatMap { $0.value }
let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.count == entries.count)
let accountArticleIds = Set(accountArticles.map { $0.articleID })
let missingIds = articleIds.subtracting(accountArticleIds)
XCTAssertTrue(missingIds.isEmpty)
}
func testCancelUpdateAccount() {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log)
let completionExpectation = expectation(description: "Did Finish")
update.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(update)
update.cancel()
waitForExpectations(timeout: 2)
let entries = testItems.flatMap { $0.value }
let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.isEmpty)
}
}

View File

@ -1 +0,0 @@
{"id":"user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/global.must","items":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
//
// TestGetCollectionsService.swift
// AccountTests
//
// Created by Kiel Gillard on 30/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
final class TestGetCollectionsService: FeedlyGetCollectionsService {
var mockResult: Result<[FeedlyCollection], Error>?
var getCollectionsExpectation: XCTestExpectation?
func getCollections(completionHandler: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
guard let result = mockResult else {
XCTFail("Missing mock result. Test may time out because the completion will not be called.")
return
}
DispatchQueue.main.async {
completionHandler(result)
self.getCollectionsExpectation?.fulfill()
}
}
}

View File

@ -0,0 +1,80 @@
//
// TestGetPagedStreamContentsService.swift
// AccountTests
//
// Created by Kiel Gillard on 28/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
final class TestGetPagedStreamContentsService: FeedlyGetStreamContentsService {
var parameterTester: ((FeedlyResourceId, String?, Date?, Bool?) -> ())?
var getStreamContentsExpectation: XCTestExpectation?
var pages = [String: FeedlyStream]()
func addAtLeastOnePage(for resource: FeedlyResourceId, continuations: [String], numberOfEntriesPerPage count: Int) {
pages = [String: FeedlyStream](minimumCapacity: continuations.count + 1)
// A continuation is an identifier for the next page.
// The first page has a nil identifier.
// The last page has no next page, so the next continuation value for that page is nil.
// Therefore, each page needs to know the identifier of the next page.
for index in -1..<continuations.count {
let nextIndex = index + 1
let continuation: String? = nextIndex < continuations.count ? continuations[nextIndex] : nil
let page = makeStreamContents(for: resource, continuation: continuation, between: 0..<count)
let key = TestGetPagedStreamContentsService.getPagingKey(for: resource, continuation: index < 0 ? nil : continuations[index])
pages[key] = page
}
}
private func makeStreamContents(for resource: FeedlyResourceId, continuation: String?, between range: Range<Int>) -> FeedlyStream {
let entries = range.map { index -> FeedlyEntry in
let content = FeedlyEntry.Content(content: "Content \(index)",
direction: .leftToRight)
let origin = FeedlyOrigin(title: "Origin \(index)",
streamId: resource.id,
htmlUrl: "http://localhost/feedly/origin/\(index)")
return FeedlyEntry(id: "/articles/\(index)",
title: "Article \(index)",
content: content,
summary: content,
author: nil,
published: Date(),
updated: nil,
origin: origin,
canonical: nil,
alternate: nil,
unread: true,
tags: nil,
categories: nil,
enclosure: nil)
}
let stream = FeedlyStream(id: resource.id, updated: nil, continuation: continuation, items: entries)
return stream
}
static func getPagingKey(for stream: FeedlyResourceId, continuation: String?) -> String {
return "\(stream.id)@\(continuation ?? "")"
}
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
let key = TestGetPagedStreamContentsService.getPagingKey(for: resource, continuation: continuation)
guard let page = pages[key] else {
XCTFail("Missing page for \(resource.id) and continuation \(String(describing: continuation)). Test may time out because the completion will not be called.")
return
}
parameterTester?(resource, continuation, newerThan, unreadOnly)
DispatchQueue.main.async {
completionHandler(.success(page))
self.getStreamContentsExpectation?.fulfill()
}
}
}

View File

@ -0,0 +1,56 @@
//
// TestGetPagedStreamIdsService.swift
// AccountTests
//
// Created by Kiel Gillard on 29/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
final class TestGetPagedStreamIdsService: FeedlyGetStreamIdsService {
var parameterTester: ((FeedlyResourceId, String?, Date?, Bool?) -> ())?
var getStreamIdsExpectation: XCTestExpectation?
var pages = [String: FeedlyStreamIds]()
func addAtLeastOnePage(for resource: FeedlyResourceId, continuations: [String], numberOfEntriesPerPage count: Int) {
pages = [String: FeedlyStreamIds](minimumCapacity: continuations.count + 1)
// A continuation is an identifier for the next page.
// The first page has a nil identifier.
// The last page has no next page, so the next continuation value for that page is nil.
// Therefore, each page needs to know the identifier of the next page.
for index in -1..<continuations.count {
let nextIndex = index + 1
let continuation: String? = nextIndex < continuations.count ? continuations[nextIndex] : nil
let page = makeStreamIds(for: resource, continuation: continuation, between: 0..<count)
let key = TestGetPagedStreamIdsService.getPagingKey(for: resource, continuation: index < 0 ? nil : continuations[index])
pages[key] = page
}
}
private func makeStreamIds(for resource: FeedlyResourceId, continuation: String?, between range: Range<Int>) -> FeedlyStreamIds {
let entryIds = range.map { _ in UUID().uuidString }
let stream = FeedlyStreamIds(continuation: continuation, ids: entryIds)
return stream
}
static func getPagingKey(for stream: FeedlyResourceId, continuation: String?) -> String {
return "\(stream.id)@\(continuation ?? "")"
}
func getStreamIds(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
let key = TestGetPagedStreamIdsService.getPagingKey(for: resource, continuation: continuation)
guard let page = pages[key] else {
XCTFail("Missing page for \(resource.id) and continuation \(String(describing: continuation)). Test may time out because the completion will not be called.")
return
}
parameterTester?(resource, continuation, newerThan, unreadOnly)
DispatchQueue.main.async {
completionHandler(.success(page))
self.getStreamIdsExpectation?.fulfill()
}
}
}

View File

@ -0,0 +1,49 @@
//
// TestGetStreamContentsService.swift
// AccountTests
//
// Created by Kiel Gillard on 28/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
final class TestGetStreamContentsService: FeedlyGetStreamContentsService {
var mockResult: Result<FeedlyStream, Error>?
var parameterTester: ((FeedlyResourceId, String?, Date?, Bool?) -> ())?
var getStreamContentsExpectation: XCTestExpectation?
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard let result = mockResult else {
XCTFail("Missing mock result. Test may time out because the completion will not be called.")
return
}
parameterTester?(resource, continuation, newerThan, unreadOnly)
DispatchQueue.main.async {
completionHandler(result)
self.getStreamContentsExpectation?.fulfill()
}
}
func makeMockFeedlyEntryItem() -> [FeedlyEntry] {
let origin = FeedlyOrigin(title: "XCTest@localhost", streamId: "user/12345/category/67890", htmlUrl: "http://localhost/nnw/xctest")
let content = FeedlyEntry.Content(content: "In the beginning...", direction: .leftToRight)
let items = [FeedlyEntry(id: "feeds/0/article/0",
title: "RSS Reader Ingests Man",
content: content,
summary: content,
author: nil,
published: Date(),
updated: nil,
origin: origin,
canonical: nil,
alternate: nil,
unread: true,
tags: nil,
categories: nil,
enclosure: nil)]
return items
}
}

View File

@ -0,0 +1,29 @@
//
// TestGetStreamIdsService.swift
// AccountTests
//
// Created by Kiel Gillard on 29/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
final class TestGetStreamIdsService: FeedlyGetStreamIdsService {
var mockResult: Result<FeedlyStreamIds, Error>?
var parameterTester: ((FeedlyResourceId, String?, Date?, Bool?) -> ())?
var getStreamIdsExpectation: XCTestExpectation?
func getStreamIds(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
guard let result = mockResult else {
XCTFail("Missing mock result. Test may time out because the completion will not be called.")
return
}
parameterTester?(resource, continuation, newerThan, unreadOnly)
DispatchQueue.main.async {
completionHandler(result)
self.getStreamIdsExpectation?.fulfill()
}
}
}

View File

@ -0,0 +1,26 @@
//
// TestMarkArticlesService.swift
// AccountTests
//
// Created by Kiel Gillard on 30/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class TestMarkArticlesService: FeedlyMarkArticlesService {
var didMarkExpectation: XCTestExpectation?
var parameterTester: ((Set<String>, FeedlyMarkAction) -> ())?
var mockResult: Result<Void, Error> = .success(())
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completionHandler: @escaping (Result<Void, Error>) -> ()) {
DispatchQueue.main.async {
self.parameterTester?(articleIds, action)
completionHandler(self.mockResult)
self.didMarkExpectation?.fulfill()
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,296 @@
{
"ids": [
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d947baaaa:15c6:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d9444b6ae:155b:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d942c0878:2de:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93f9d9be:2ab:53b826a2",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d93d6ce8f:147d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d93d6ce8f:147c:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93c1a015:251:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93b34eba:241:53b826a2",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d93b31822:142f:d4506071",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d93b31822:142e:d4506071",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d93b31822:142d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d939fda8c:13f2:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93950469:21b:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93950469:21a:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d938aedbe:216:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93852581:211:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9380dda0:209:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9376eefc:202:53b826a2",
"kv2DIas8GblflohzMAcClzUErTYUYammDtqm4auH/og=_16d936e2abe:1359:d4506071",
"kv2DIas8GblflohzMAcClzUErTYUYammDtqm4auH/og=_16d936e2abe:1358:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d936d06f3:1ef:53b826a2",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d9368e67a:133d:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d93445712:12e2:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d933ea3c0:c4:53b826a2",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d9331f22f:1299:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d9331f22f:1298:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d931c60da:8b:53b826a2",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d93167a9c:7c:53b826a2",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d930e3ae3:122b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92fafd47:119c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92fafd47:119b:d4506071",
"ja4A8niNlyAfffczL726vcmEyvPHXJ4+zSYKI4xWdUg=_16d92efb228:118b:d4506071",
"ja4A8niNlyAfffczL726vcmEyvPHXJ4+zSYKI4xWdUg=_16d92efb228:118a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92e40b08:11:53b826a2",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d92d74711:113f:d4506071",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d92d74711:113e:d4506071",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d92d74711:113d:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d92d6203d:113b:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92cb9b83:d47:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92cb9b83:d46:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92c4095a:10fb:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92c4095a:10fa:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92c19339:d3b:fc4690a0",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d92c10fc0:10f0:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92ad8234:d27:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92a356f6:d1f:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92936251:d0c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92936251:d0b:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d928d1550:103e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d928d1550:103d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d927afac9:ce5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d927afac9:ce4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d926b25dc:cd9:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d9267e977:fc9:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d925d00c6:ccf:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d925d00c6:cce:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d925620e1:f90:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d924895c3:cb8:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:cac:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:cab:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:caa:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:ca9:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d922ebbce:c9e:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d922ebbce:c9d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d921f2cdb:eef:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d921f2cdb:eee:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d920c42e7:c86:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91f7f422:c70:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d91e838fb:e54:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91d9837f:c5c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91d3a580:c56:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c49:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c48:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c47:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d91c0ea34:dcf:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d91b143b9:d96:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d919d0e1b:c27:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9198dae9:c21:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3f:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3d:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3c:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3b:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3a:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b39:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b38:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b37:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b36:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b35:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c15:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c14:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c13:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afe:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afd:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afc:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9174a7bc:c02:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d90f19878:b55:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8f76c4f0:884:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8edefa36:703:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec5b3f5:6f1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec0c455:6dc:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8eafe951:655:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e7a5c11:5b8:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e47ea63:4f3:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:555:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:554:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a2:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e29a65f:44f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8e19672d:4b9:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e157c76:3ca:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8df74270:3a3:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f3:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f2:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f1:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f0:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3ef:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:359:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:358:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dcf0acd:353:fc4690a0",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d8dcbdbb5:347:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8db0dc98:33a:fc4690a0",
"ja4A8niNlyAfffczL726vcmEyvPHXJ4+zSYKI4xWdUg=_16d8dab6a11:2d6:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:335:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:334:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:298:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:297:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:296:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d928ffb:326:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d888cfb:31b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d7654f4:30d:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:309:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d74755c:222:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e5:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e4:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d603c89:2f6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e5:fc4690a0",
"AY80t/Vl4TMkqqPjtnpcUzVjYAqDn3a9sFmvt0zCpmI=_16d8d3d9585:154:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d3d59be:153:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d3:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d2:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfeb0cc:291:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfe9cb4:28d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:4a:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:49:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cf5d0ea:284:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cdcb80b:270:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cd33bb7:26a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cc927f0:260:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:760:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:75f:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cbe6239:258:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cb47ebe:252:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ca03279:23f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8c98318d:6ee:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c9638e1:23a:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b7:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b6:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c6de21e:221:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c59cf46:213:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:448:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:447:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c45920d:206:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1ff:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1fe:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c10839d:1e1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8bf29449:1ce:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ba1ae86:1b1:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8a2de26c:4a:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8a041299:26:8e83c13e",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:2:5e4732b4",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:1:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89d099d9:5:c963e369",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51566:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51565:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89942421:5148f:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8988fd3e:51455:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89797893:1f1:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d896af822:1ac:db1c1742",
"4rURtf64IHW6ygZs9tzPieSmabigMiZqHVuUPwvicWg=_16d895c218f:51370:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89520343:5132b:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d894cd2ff:108:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89471ca5:105:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d893313be:f1:db1c1742",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89254704:5121b:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d8924f769:5121a:18991ffa",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8924f6cc:51219:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d8:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d7:db1c1742",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d891b096a:511c9:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89066ab8:c5:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88f67b14:b8:db1c1742",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d88ed77ca:510a4:18991ffa",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d88e0db64:5104a:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff8:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88d1c100:a3:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88ce0d5a:a0:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88c41716:9a:db1c1742",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d88b5f1f6:50f66:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4d:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88ad1618:50f0c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88a5d80e:7b:db1c1742",
"Ai/HDbZBn/DqS4YSFb8RbnuS8su16El+mi83Mpt/WqQ=_16d88a4dca2:50e96:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d889bd061:72:db1c1742",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d888b305c:50dda:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da6:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da5:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5b:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5a:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88737305:15:db1c1742",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d886c439f:50ca1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88695b6f:f:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d885899d3:2a4e:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d88540f01:50bf6:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d8846e4fe:50bc1:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d884666a1:50bba:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d8844085c:50b99:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8841112b:2a39:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d883f2213:50b6e:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d881ce4bb:509fb:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29f0:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ef:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ee:90d684ff",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d880e78f1:50990:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8804b013:29e2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d7:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d6:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d1:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080b:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080a:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:50809:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cd:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cc:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a2:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87be329a:29aa:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a4:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87aa0b8d:299b:90d684ff",
"qxI9mPUQaH2N8pL2LRpLlzIW5hw1iiQKLdJmLkGau/I=_16d87a8b438:50635:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d879fec1f:2992:90d684ff",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d879fb6f0:50612:18991ffa",
"mstho8+q3WlqKF/sQV4c1Sm0g7GZMkqDkTxYnXthJHo=_16d879f90ef:5060a:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ee:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ed:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c8:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2982:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2981:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d876d90e1:2970:90d684ff",
"Ai/HDbZBn/DqS4YSFb8RbnuS8su16El+mi83Mpt/WqQ=_16d875a7313:503b8:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2956:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2955:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293c:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293b:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293a:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2d:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d870280bb:2920:90d684ff",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd4:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd3:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd2:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86f894e7:2914:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86be7055:28f9:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d86bdfb5a:4fc5c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e4:90d684ff",
"r0GuC4sgAzEOyudB9cuXyJaa1HYa9lXs9s0Sf3Zd858=_16d86867c9b:4fb46:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d867b7bb5:28d3:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0d:18991ffa"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"id": "user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/global.all",
"updated": 1572574658046,
"items": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,258 @@
{
"ids": [
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92cb9b83:d47:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92cb9b83:d46:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92c4095a:10fb:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d92c4095a:10fa:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92c19339:d3b:fc4690a0",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d92c10fc0:10f0:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92ad8234:d27:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92a356f6:d1f:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92936251:d0c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d92936251:d0b:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d928d1550:103e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d928d1550:103d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d927afac9:ce5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d927afac9:ce4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d926b25dc:cd9:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d9267e977:fc9:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d925d00c6:ccf:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d925d00c6:cce:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d925620e1:f90:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d924895c3:cb8:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:cac:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:cab:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:caa:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d923e9078:ca9:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d922ebbce:c9e:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d922ebbce:c9d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d921f2cdb:eef:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d921f2cdb:eee:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d920c42e7:c86:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91f7f422:c70:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d91e838fb:e54:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91d9837f:c5c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91d3a580:c56:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c49:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c48:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d91c5604c:c47:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d91c0ea34:dcf:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d91b143b9:d96:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d919d0e1b:c27:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9198dae9:c21:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3f:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3d:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3c:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3b:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b3a:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b39:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b38:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b37:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b36:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d9189e253:b35:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c15:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c14:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9188f606:c13:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afe:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afd:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d917a4e34:afc:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9174a7bc:c02:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d90f19878:b55:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8f76c4f0:884:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8edefa36:703:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec5b3f5:6f1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec0c455:6dc:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8eafe951:655:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e7a5c11:5b8:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e47ea63:4f3:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:555:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:554:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a2:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e29a65f:44f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8e19672d:4b9:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e157c76:3ca:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8df74270:3a3:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f3:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f2:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f1:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f0:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3ef:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:359:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:358:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dcf0acd:353:fc4690a0",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d8dcbdbb5:347:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8db0dc98:33a:fc4690a0",
"ja4A8niNlyAfffczL726vcmEyvPHXJ4+zSYKI4xWdUg=_16d8dab6a11:2d6:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:335:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:334:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:298:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:297:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:296:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d928ffb:326:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d888cfb:31b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d7654f4:30d:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:309:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d74755c:222:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e5:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e4:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d603c89:2f6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e5:fc4690a0",
"AY80t/Vl4TMkqqPjtnpcUzVjYAqDn3a9sFmvt0zCpmI=_16d8d3d9585:154:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d3d59be:153:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d3:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d2:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfeb0cc:291:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfe9cb4:28d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:4a:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:49:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cf5d0ea:284:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cdcb80b:270:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cd33bb7:26a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cc927f0:260:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:760:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:75f:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cbe6239:258:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cb47ebe:252:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ca03279:23f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8c98318d:6ee:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c9638e1:23a:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b7:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b6:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c6de21e:221:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c59cf46:213:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:448:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:447:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c45920d:206:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1ff:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1fe:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c10839d:1e1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8bf29449:1ce:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ba1ae86:1b1:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8a2de26c:4a:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8a041299:26:8e83c13e",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:2:5e4732b4",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:1:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89d099d9:5:c963e369",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51566:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51565:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89942421:5148f:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8988fd3e:51455:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89797893:1f1:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d896af822:1ac:db1c1742",
"4rURtf64IHW6ygZs9tzPieSmabigMiZqHVuUPwvicWg=_16d895c218f:51370:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89520343:5132b:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d894cd2ff:108:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89471ca5:105:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d893313be:f1:db1c1742",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89254704:5121b:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d8924f769:5121a:18991ffa",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8924f6cc:51219:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d8:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d7:db1c1742",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d891b096a:511c9:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89066ab8:c5:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88f67b14:b8:db1c1742",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d88ed77ca:510a4:18991ffa",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d88e0db64:5104a:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff8:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88d1c100:a3:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88ce0d5a:a0:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88c41716:9a:db1c1742",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d88b5f1f6:50f66:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4d:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88ad1618:50f0c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88a5d80e:7b:db1c1742",
"Ai/HDbZBn/DqS4YSFb8RbnuS8su16El+mi83Mpt/WqQ=_16d88a4dca2:50e96:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d889bd061:72:db1c1742",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d888b305c:50dda:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da6:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da5:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5b:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5a:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88737305:15:db1c1742",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d886c439f:50ca1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88695b6f:f:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d885899d3:2a4e:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d88540f01:50bf6:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d8846e4fe:50bc1:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d884666a1:50bba:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d8844085c:50b99:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8841112b:2a39:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d883f2213:50b6e:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d881ce4bb:509fb:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29f0:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ef:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ee:90d684ff",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d880e78f1:50990:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8804b013:29e2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d7:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d6:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d1:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080b:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080a:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:50809:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cd:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cc:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a2:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87be329a:29aa:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a4:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87aa0b8d:299b:90d684ff",
"qxI9mPUQaH2N8pL2LRpLlzIW5hw1iiQKLdJmLkGau/I=_16d87a8b438:50635:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d879fec1f:2992:90d684ff",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d879fb6f0:50612:18991ffa",
"mstho8+q3WlqKF/sQV4c1Sm0g7GZMkqDkTxYnXthJHo=_16d879f90ef:5060a:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ee:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ed:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c8:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2982:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2981:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d876d90e1:2970:90d684ff",
"Ai/HDbZBn/DqS4YSFb8RbnuS8su16El+mi83Mpt/WqQ=_16d875a7313:503b8:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2956:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2955:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293c:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293b:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293a:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2d:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d870280bb:2920:90d684ff",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd4:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd3:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd2:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86f894e7:2914:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86be7055:28f9:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d86bdfb5a:4fc5c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e4:90d684ff",
"r0GuC4sgAzEOyudB9cuXyJaa1HYa9lXs9s0Sf3Zd858=_16d86867c9b:4fb46:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d867b7bb5:28d3:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0d:18991ffa"
]
}

View File

@ -0,0 +1,5 @@
{
"id": "user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/global.all",
"updated": 1572574658046,
"items": []
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,178 @@
{
"ids": [
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f1:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f0:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3ef:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:359:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:358:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dcf0acd:353:fc4690a0",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d8dcbdbb5:347:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8db0dc98:33a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:335:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:334:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:298:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:297:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:296:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d928ffb:326:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d888cfb:31b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d7654f4:30d:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:309:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d74755c:222:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e5:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e4:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d603c89:2f6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e5:fc4690a0",
"AY80t/Vl4TMkqqPjtnpcUzVjYAqDn3a9sFmvt0zCpmI=_16d8d3d9585:154:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d3d59be:153:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d3:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d2:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfeb0cc:291:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfe9cb4:28d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:4a:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:49:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cf5d0ea:284:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cdcb80b:270:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cd33bb7:26a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cc927f0:260:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:760:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:75f:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cbe6239:258:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cb47ebe:252:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ca03279:23f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8c98318d:6ee:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c9638e1:23a:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b7:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b6:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c6de21e:221:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c59cf46:213:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:448:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:447:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c45920d:206:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1ff:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1fe:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c10839d:1e1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8bf29449:1ce:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ba1ae86:1b1:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8a2de26c:4a:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8a041299:26:8e83c13e",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:2:5e4732b4",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:1:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89d099d9:5:c963e369",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51566:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51565:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89942421:5148f:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8988fd3e:51455:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89797893:1f1:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d896af822:1ac:db1c1742",
"4rURtf64IHW6ygZs9tzPieSmabigMiZqHVuUPwvicWg=_16d895c218f:51370:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89520343:5132b:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d894cd2ff:108:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89471ca5:105:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d893313be:f1:db1c1742",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89254704:5121b:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d8924f769:5121a:18991ffa",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8924f6cc:51219:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d8:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d7:db1c1742",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d891b096a:511c9:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89066ab8:c5:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88f67b14:b8:db1c1742",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d88ed77ca:510a4:18991ffa",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d88e0db64:5104a:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff8:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88d1c100:a3:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88ce0d5a:a0:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88c41716:9a:db1c1742",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d88b5f1f6:50f66:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4d:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88ad1618:50f0c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88a5d80e:7b:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d889bd061:72:db1c1742",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d888b305c:50dda:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da6:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da5:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5b:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5a:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88737305:15:db1c1742",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d886c439f:50ca1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88695b6f:f:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d885899d3:2a4e:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d88540f01:50bf6:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d8846e4fe:50bc1:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d884666a1:50bba:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d8844085c:50b99:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8841112b:2a39:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d883f2213:50b6e:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d881ce4bb:509fb:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29f0:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ef:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ee:90d684ff",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d880e78f1:50990:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8804b013:29e2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d7:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d6:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d1:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080b:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080a:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:50809:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cd:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cc:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a2:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87be329a:29aa:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a4:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87aa0b8d:299b:90d684ff",
"qxI9mPUQaH2N8pL2LRpLlzIW5hw1iiQKLdJmLkGau/I=_16d87a8b438:50635:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d879fec1f:2992:90d684ff",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d879fb6f0:50612:18991ffa",
"mstho8+q3WlqKF/sQV4c1Sm0g7GZMkqDkTxYnXthJHo=_16d879f90ef:5060a:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ee:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ed:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c8:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2982:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2981:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d876d90e1:2970:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2956:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2955:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293c:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293b:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293a:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2d:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d870280bb:2920:90d684ff",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd4:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd3:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd2:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86f894e7:2914:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86be7055:28f9:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d86bdfb5a:4fc5c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e4:90d684ff",
"r0GuC4sgAzEOyudB9cuXyJaa1HYa9lXs9s0Sf3Zd858=_16d86867c9b:4fb46:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d867b7bb5:28d3:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0d:18991ffa"
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,198 @@
{
"ids": [
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d9174a7bc:c02:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d90f19878:b55:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8f76c4f0:884:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8edefa36:703:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec5b3f5:6f1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ec0c455:6dc:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8eafe951:655:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e7a5c11:5b8:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e47ea63:4f3:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:555:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8e42013b:554:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e33b2d9:4a2:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e29a65f:44f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8e19672d:4b9:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8e157c76:3ca:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8df74270:3a3:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f3:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f2:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f1:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f0:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3ef:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:359:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:358:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dcf0acd:353:fc4690a0",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d8dcbdbb5:347:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8db0dc98:33a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:335:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:334:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:298:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:297:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:296:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d928ffb:326:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d888cfb:31b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d7654f4:30d:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:309:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d74755c:222:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e5:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e4:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d603c89:2f6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e5:fc4690a0",
"AY80t/Vl4TMkqqPjtnpcUzVjYAqDn3a9sFmvt0zCpmI=_16d8d3d9585:154:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d3d59be:153:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d3:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d2:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfeb0cc:291:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfe9cb4:28d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:4a:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:49:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cf5d0ea:284:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cdcb80b:270:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cd33bb7:26a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cc927f0:260:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:760:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:75f:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cbe6239:258:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cb47ebe:252:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ca03279:23f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8c98318d:6ee:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c9638e1:23a:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b7:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b6:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c6de21e:221:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c59cf46:213:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:448:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:447:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c45920d:206:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1ff:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1fe:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c10839d:1e1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8bf29449:1ce:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ba1ae86:1b1:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8a2de26c:4a:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8a041299:26:8e83c13e",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:2:5e4732b4",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:1:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89d099d9:5:c963e369",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51566:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51565:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89942421:5148f:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8988fd3e:51455:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89797893:1f1:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d896af822:1ac:db1c1742",
"4rURtf64IHW6ygZs9tzPieSmabigMiZqHVuUPwvicWg=_16d895c218f:51370:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89520343:5132b:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d894cd2ff:108:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89471ca5:105:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d893313be:f1:db1c1742",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89254704:5121b:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d8924f769:5121a:18991ffa",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8924f6cc:51219:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d8:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d7:db1c1742",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d891b096a:511c9:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89066ab8:c5:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88f67b14:b8:db1c1742",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d88ed77ca:510a4:18991ffa",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d88e0db64:5104a:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff8:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88d1c100:a3:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88ce0d5a:a0:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88c41716:9a:db1c1742",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d88b5f1f6:50f66:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4d:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88ad1618:50f0c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88a5d80e:7b:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d889bd061:72:db1c1742",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d888b305c:50dda:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da6:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da5:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5b:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5a:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88737305:15:db1c1742",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d886c439f:50ca1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88695b6f:f:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d885899d3:2a4e:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d88540f01:50bf6:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d8846e4fe:50bc1:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d884666a1:50bba:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d8844085c:50b99:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8841112b:2a39:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d883f2213:50b6e:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d881ce4bb:509fb:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29f0:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ef:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ee:90d684ff",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d880e78f1:50990:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8804b013:29e2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d7:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d6:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d1:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080b:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080a:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:50809:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cd:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cc:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a2:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87be329a:29aa:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a4:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87aa0b8d:299b:90d684ff",
"qxI9mPUQaH2N8pL2LRpLlzIW5hw1iiQKLdJmLkGau/I=_16d87a8b438:50635:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d879fec1f:2992:90d684ff",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d879fb6f0:50612:18991ffa",
"mstho8+q3WlqKF/sQV4c1Sm0g7GZMkqDkTxYnXthJHo=_16d879f90ef:5060a:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ee:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ed:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c8:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2982:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2981:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d876d90e1:2970:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2956:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2955:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293c:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293b:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293a:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2d:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d870280bb:2920:90d684ff",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd4:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd3:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd2:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86f894e7:2914:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86be7055:28f9:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d86bdfb5a:4fc5c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e4:90d684ff",
"r0GuC4sgAzEOyudB9cuXyJaa1HYa9lXs9s0Sf3Zd858=_16d86867c9b:4fb46:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d867b7bb5:28d3:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0d:18991ffa"
]
}

View File

@ -0,0 +1,5 @@
{
"id": "user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/global.all",
"updated": 1572574658046,
"items": []
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,177 @@
{
"ids": [
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3f0:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8de25f3c:3ef:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:359:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dd92b46:358:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35c:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35b:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8dd41847:35a:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dcf0acd:353:fc4690a0",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d8dcbdbb5:347:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8dc4fe3f:34a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8db0dc98:33a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:335:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8da6e95b:334:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:298:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:297:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d9d23f4:296:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d928ffb:326:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d888cfb:31b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d7654f4:30d:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30c:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30b:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:30a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d764432:309:fc4690a0",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d74755c:222:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21e:d4506071",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8d744f3f:21d:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e5:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d662f72:1e4:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d603c89:2f6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d4c2bf2:2e5:fc4690a0",
"AY80t/Vl4TMkqqPjtnpcUzVjYAqDn3a9sFmvt0zCpmI=_16d8d3d9585:154:d4506071",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8d3d59be:153:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d3:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d380a62:2d2:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11e:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8d2f3a93:11d:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a6:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a5:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8d19c4b0:2a4:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfeb0cc:291:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cfe9cb4:28d:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:4a:d4506071",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cf8458f:49:d4506071",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cf5d0ea:284:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cdcb80b:270:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cd33bb7:26a:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cc927f0:260:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:760:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8cc153e9:75f:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cbe6239:258:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8cb47ebe:252:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ca03279:23f:fc4690a0",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d8c98318d:6ee:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c9638e1:23a:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b7:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c8a5f44:6b6:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c6de21e:221:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c59cf46:213:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:448:5e4732b4",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8c536ab3:447:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c45920d:206:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1ff:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c3b82d3:1fe:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8c10839d:1e1:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8bf29449:1ce:fc4690a0",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8ba1ae86:1b1:fc4690a0",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8a2de26c:4a:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8a041299:26:8e83c13e",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:2:5e4732b4",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d89eeb096:1:5e4732b4",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89d099d9:5:c963e369",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51566:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89bff80b:51565:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89942421:5148f:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d8988fd3e:51455:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89797893:1f1:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d896af822:1ac:db1c1742",
"4rURtf64IHW6ygZs9tzPieSmabigMiZqHVuUPwvicWg=_16d895c218f:51370:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d89520343:5132b:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d894cd2ff:108:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89471ca5:105:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d893313be:f1:db1c1742",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d89254704:5121b:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d8924f769:5121a:18991ffa",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d8924f6cc:51219:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d8:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d891f3096:d7:db1c1742",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d891b096a:511c9:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d89066ab8:c5:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88f67b14:b8:db1c1742",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d88ed77ca:510a4:18991ffa",
"wEPPtpyxm18xeuKJtd0RXOmRfdiYTcxSNO1zsjNlPdg=_16d88e0db64:5104a:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff8:18991ffa",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d88da4abd:50ff7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88d1c100:a3:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88ce0d5a:a0:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88c41716:9a:db1c1742",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d88b5f1f6:50f66:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4d:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d88b25c45:50f4c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88ad1618:50f0c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88a5d80e:7b:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d889bd061:72:db1c1742",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d888b305c:50dda:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da6:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d887b3ce7:50da5:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5c:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5b:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d88761c68:50d5a:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88737305:15:db1c1742",
"ZTHt7g74IlVC5A2IgEvcn/aop5teo99gzFaGU2TCGxs=_16d886c439f:50ca1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d88695b6f:f:db1c1742",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d885899d3:2a4e:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d88540f01:50bf6:18991ffa",
"+jHfsXnBCVfCstSIW1WDumAyigT4rnsUPnI5WFxgnAU=_16d8846e4fe:50bc1:18991ffa",
"ITR2bp1hhxjNSFKlSuZR7gUTTcxmHRq2TwhCgV9CifI=_16d884666a1:50bba:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d8844085c:50b99:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8841112b:2a39:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d883f2213:50b6e:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d881ce4bb:509fb:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29f0:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ef:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d880ef0b1:29ee:90d684ff",
"E51hsZSss+6XSdMAYelFIdkn3CDBqFF2zAtZLRbxUrQ=_16d880e78f1:50990:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d8804b013:29e2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d7:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f42846:29d6:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d2:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87f08676:29d1:90d684ff",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080b:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:5080a:18991ffa",
"A4VVV8iwf2HD21qpAa7ABDI8MY8lm8dIbsVbE6sVKNA=_16d87e5a8de:50809:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cd:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d87d5a0da:507cc:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a2:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d87d11796:507a1:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87be329a:29aa:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87b7bc0b:29a4:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87aa0b8d:299b:90d684ff",
"qxI9mPUQaH2N8pL2LRpLlzIW5hw1iiQKLdJmLkGau/I=_16d87a8b438:50635:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d879fec1f:2992:90d684ff",
"RFlzskW4NhJjlZfijOSI8IXqM9+zz6V9qnDVl1gxaJs=_16d879fb6f0:50612:18991ffa",
"mstho8+q3WlqKF/sQV4c1Sm0g7GZMkqDkTxYnXthJHo=_16d879f90ef:5060a:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ee:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d879e7b4c:505ed:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c8:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d879a0784:505c7:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2982:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d87856d15:2981:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d876d90e1:2970:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2956:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d874f7ba1:2955:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293c:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293b:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d873148ae:293a:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d872c01bd:4ff2d:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d870280bb:2920:90d684ff",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd4:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd3:18991ffa",
"1JeSDrJ7WnuWJBtfP8UBdStvgnDdH8wtt082mlVgo4k=_16d86f8e582:4fdd2:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86f894e7:2914:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d86be7055:28f9:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d86bdfb5a:4fc5c:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e5:90d684ff",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d869ef75d:28e4:90d684ff",
"r0GuC4sgAzEOyudB9cuXyJaa1HYa9lXs9s0Sf3Zd858=_16d86867c9b:4fb46:18991ffa",
"AxO6mug+YPRclcA3EJcsykvvS1qcjXH62IXONGWCBII=_16d867b7bb5:28d3:90d684ff",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0e:18991ffa",
"BmoAzSEWHFzR01wyxBZAhNEo11Vy8oDR1qKDe+tKVEQ=_16d865004b0:4fa0d:18991ffa"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
{
"ids": []
}

View File

@ -16,7 +16,7 @@ class TestAccountManager {
static let shared = TestAccountManager()
var accountsFolder: URL {
return FileManager.default.temporaryDirectory
return try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
}
@ -44,7 +44,7 @@ class TestAccountManager {
try FileManager.default.removeItem(atPath: account.dataFolder)
}
catch let error as CocoaError where error.code == .fileNoSuchFile {
print("Unable to delete folder at: \(account.dataFolder) because \(error)")
}
catch {
assertionFailure("Could not delete folder at: \(account.dataFolder) because \(error)")

View File

@ -10,6 +10,10 @@ import Foundation
import RSWeb
import XCTest
protocol TestTransportMockResponseProviding: class {
func mockResponseFileUrl(for components: URLComponents) -> URL?
}
final class TestTransport: Transport {
enum TestTransportError: String, Error {
@ -19,12 +23,7 @@ final class TestTransport: Transport {
var testFiles = [String: String]()
var testStatusCodes = [String: Int]()
/// Allows tests to filter time sensitive state out to make matching against test data easier.
var blacklistedQueryItemNames = Set([
"newerThan", // Feedly: Mock data has a fixed date.
"unreadOnly", // Feedly: Mock data is read/unread by test expectation.
"count", // Feedly: Mock data is limited by test expectation.
])
weak var mockResponseFileUrlProvider: TestTransportMockResponseProviding?
private func httpResponse(for request: URLRequest, statusCode: Int = 200) -> HTTPURLResponse {
guard let url = request.url else {
@ -35,41 +34,45 @@ final class TestTransport: Transport {
func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
guard let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
completion(.failure(TestTransportError.invalidState))
return
}
components.queryItems = components
.queryItems?
.filter { !blacklistedQueryItemNames.contains($0.name) }
guard let urlString = components.url?.absoluteString else {
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
completion(.failure(TestTransportError.invalidState))
return
}
let urlString = url.absoluteString
let response = httpResponse(for: request, statusCode: testStatusCodes[urlString] ?? 200)
let testFileURL: URL
var mockResponseFound = false
for (key, testFileName) in testFiles where urlString.contains(key) {
let testFileURL = Bundle(for: TestTransport.self).resourceURL!.appendingPathComponent(testFileName)
let data = try! Data(contentsOf: testFileURL)
DispatchQueue.global(qos: .background).async {
completion(.success((response, data)))
if let provider = mockResponseFileUrlProvider {
guard let providerUrl = provider.mockResponseFileUrl(for: components) else {
XCTFail("Test behaviour undefined. Mock provider failed to provide non-nil URL for \(components).")
return
}
mockResponseFound = true
break
}
if !mockResponseFound {
// XCTFail("Missing mock response for: \(urlString)")
testFileURL = providerUrl
} else if let testKeyAndFileName = testFiles.first(where: { urlString.contains($0.key) }) {
testFileURL = Bundle(for: TestTransport.self).resourceURL!.appendingPathComponent(testKeyAndFileName.value)
} else {
// XCTFail("Missing mock response for: \(urlString)")
print("***\nWARNING: \(self) missing mock response for:\n\(urlString)\n***")
DispatchQueue.global(qos: .background).async {
completion(.success((response, nil)))
}
return
}
do {
let data = try Data(contentsOf: testFileURL)
DispatchQueue.global(qos: .background).async {
completion(.success((response, data)))
}
} catch {
XCTFail("Unable to read file at \(testFileURL) because \(error).")
DispatchQueue.global(qos: .background).async {
completion(.failure(error))
}
}
}
func send(request: URLRequest, method: String, completion: @escaping (Result<Void, Error>) -> Void) {

View File

@ -53,171 +53,6 @@ final class FeedlyAPICaller {
return baseUrlComponents.host
}
func getCollections(completionHandler: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/collections"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
// URLSession.shared.dataTask(with: request) { (data, response, error) in
// print(String(data: data!, encoding: .utf8))
// }.resume()
//
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
func getStream(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/streams/contents"
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
if let value = continuation, !value.isEmpty {
let queryItem = URLQueryItem(name: "continuation", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "1000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
enum MarkAction {
case read
case unread
case saved
case unsaved
var actionValue: String {
switch self {
case .read:
return "markAsRead"
case .unread:
return "keepUnread"
case .saved:
return "markAsSaved"
case .unsaved:
return "markAsUnsaved"
}
}
}
private struct MarkerEntriesBody: Encodable {
let type = "entries"
var action: String
var entryIds: [String]
}
func mark(_ articleIds: Set<String>, as action: MarkAction, completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/markers"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
do {
let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIds))
let encoder = JSONEncoder()
let data = try encoder.encode(body)
request.httpBody = data
} catch {
return DispatchQueue.main.async {
completionHandler(.failure(error))
}
}
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (httpResponse, _)):
if httpResponse.statusCode == 200 {
completionHandler(.success(()))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
func importOpml(_ opmlData: Data, completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
@ -538,3 +373,264 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
}
}
}
extension FeedlyAPICaller: FeedlyGetCollectionsService {
func getCollections(completionHandler: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/collections"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
extension FeedlyAPICaller: FeedlyGetStreamContentsService {
func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/streams/contents"
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
if let value = continuation, !value.isEmpty {
let queryItem = URLQueryItem(name: "continuation", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "1000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
extension FeedlyAPICaller: FeedlyGetStreamIdsService {
func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/streams/ids"
var queryItems = [URLQueryItem]()
if let date = newerThan {
let value = String(Int(date.timeIntervalSince1970 * 1000))
let queryItem = URLQueryItem(name: "newerThan", value: value)
queryItems.append(queryItem)
}
if let flag = unreadOnly {
let value = flag ? "true" : "false"
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
queryItems.append(queryItem)
}
if let value = continuation, !value.isEmpty {
let queryItem = URLQueryItem(name: "continuation", value: value)
queryItems.append(queryItem)
}
queryItems.append(contentsOf: [
URLQueryItem(name: "count", value: "1000"),
URLQueryItem(name: "streamId", value: resource.id),
])
components.queryItems = queryItems
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
transport.send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
extension FeedlyAPICaller: FeedlyGetEntriesService {
func getEntries(for ids: Set<String>, completionHandler: @escaping (Result<[FeedlyEntry], Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/entries/.mget"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
do {
let body = Array(ids)
let encoder = JSONEncoder()
let data = try encoder.encode(body)
request.httpBody = data
} catch {
return DispatchQueue.main.async {
completionHandler(.failure(error))
}
}
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
transport.send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, entries)):
if let response = entries {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
extension FeedlyAPICaller: FeedlyMarkArticlesService {
private struct MarkerEntriesBody: Encodable {
let type = "entries"
var action: String
var entryIds: [String]
}
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/markers"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
do {
let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIds))
let encoder = JSONEncoder()
let data = try encoder.encode(body)
request.httpBody = data
} catch {
return DispatchQueue.main.async {
completionHandler(.failure(error))
}
}
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (httpResponse, _)):
if httpResponse.statusCode == 200 {
completionHandler(.success(()))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}

View File

@ -46,6 +46,8 @@ final class FeedlyAccountDelegate: AccountDelegate {
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
private let database: SyncDatabase
private weak var currentSyncAllOperation: FeedlySyncAllOperation?
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
if let transport = transport {
@ -76,23 +78,44 @@ final class FeedlyAccountDelegate: AccountDelegate {
// MARK: Account API
private var syncStrategy: FeedlySyncStrategy?
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
let date = Date()
assert(Thread.isMainThread)
guard currentSyncAllOperation == nil else {
os_log(.debug, log: log, "Ignoring refreshAll: Feedly sync already in progress.")
completion(.success(()))
return
}
guard let credentials = credentials else {
os_log(.debug, log: log, "Ignoring refreshAll: Feedly account has no credentials.")
completion(.failure(FeedlyAccountDelegateError.notLoggedIn))
return
}
let log = self.log
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
syncStrategy?.startSync { result in
os_log(.debug, log: log, "Sync took %.3f seconds", -date.timeIntervalSinceNow)
let operation = FeedlySyncAllOperation(account: account, credentials: credentials, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetch, log: log)
let date = Date()
operation.syncCompletionHandler = { [weak self] result in
self?.accountMetadata?.lastArticleFetch = date
os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow)
progress.completeTask()
completion(result)
}
currentSyncAllOperation = operation
OperationQueue.main.addOperation(operation)
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
// Ensure remote articles have the same status as they do locally.
let send = FeedlySendArticleStatusesOperation(database: database, caller: caller, log: log)
let send = FeedlySendArticleStatusesOperation(database: database, service: caller, log: log)
send.completionBlock = {
DispatchQueue.main.async {
completion()
@ -112,9 +135,32 @@ final class FeedlyAccountDelegate: AccountDelegate {
/// - Parameter account: The account whose articles have a remote status.
/// - Parameter completion: Call on the main queue.
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
refreshAll(for: account) { _ in
guard let credentials = credentials else {
return completion()
}
let group = DispatchGroup()
let getUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: caller, newerThan: nil, log: log)
group.enter()
getUnread.completionBlock = {
group.leave()
}
let getStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, service: caller, log: log)
group.enter()
getStarred.completionBlock = {
group.leave()
}
group.notify(queue: .main) {
completion()
}
OperationQueue.main.addOperations([getUnread, getStarred], waitUntilFinished: false)
}
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
@ -410,11 +456,6 @@ final class FeedlyAccountDelegate: AccountDelegate {
func accountDidInitialize(_ account: Account) {
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
syncStrategy = FeedlySyncStrategy(account: account,
caller: caller,
database: database,
log: log)
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {

View File

@ -68,9 +68,9 @@ final class FeedlyAddFeedRequest {
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
createFeeds.addDependency(addRequest)
let getStream: FeedlyGetStreamOperation? = {
let getStream: FeedlyGetStreamContentsOperation? = {
if refreshes {
let op = FeedlyGetStreamOperation(account: account, resourceProvider: addRequest, caller: caller, newerThan: nil)
let op = FeedlyGetStreamContentsOperation(account: account, resourceProvider: addRequest, service: caller, newerThan: nil)
op.addDependency(createFeeds)
return op
}
@ -79,7 +79,7 @@ final class FeedlyAddFeedRequest {
let organiseByFeed: FeedlyOrganiseParsedItemsByFeedOperation? = {
if let getStream = getStream {
let op = FeedlyOrganiseParsedItemsByFeedOperation(account: account, entryProvider: getStream, log: log)
let op = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: getStream, log: log)
op.addDependency(getStream)
return op
}

View File

@ -25,7 +25,7 @@ struct FeedlyFeedContainerValidator {
throw FeedlyAccountDelegateError.notLoggedIn
}
let uncategorized = FeedlyCategoryResourceId.uncategorized(for: userId)
let uncategorized = FeedlyCategoryResourceId.Global.uncategorized(for: userId)
guard collectionId != uncategorized.id else {
throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder)

View File

@ -84,7 +84,7 @@ struct FeedlyEntryParser {
var parsedItemRepresentation: ParsedItem {
return ParsedItem(syncServiceID: id,
uniqueID: id,
uniqueID: id, // This value seems to get ignored or replaced.
feedURL: feedUrl,
url: nil,
externalURL: externalUrl,

View File

@ -43,19 +43,32 @@ extension FeedlyFeedResourceId {
struct FeedlyCategoryResourceId: FeedlyResourceId {
var id: String
static func uncategorized(for userId: String) -> FeedlyCategoryResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/category/global.uncategorized"
return FeedlyCategoryResourceId(id: id)
enum Global {
static func uncategorized(for userId: String) -> FeedlyCategoryResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/category/global.uncategorized"
return FeedlyCategoryResourceId(id: id)
}
/// All articles from all the feeds the user subscribes to.
static func all(for userId: String) -> FeedlyCategoryResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/category/global.all"
return FeedlyCategoryResourceId(id: id)
}
}
}
struct FeedlyTagResourceId: FeedlyResourceId {
var id: String
static func saved(for userId: String) -> FeedlyTagResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/tag/global.saved"
return FeedlyTagResourceId(id: id)
enum Global {
static func saved(for userId: String) -> FeedlyTagResourceId {
// https://developer.feedly.com/cloud/#global-resource-ids
let id = "user/\(userId)/tag/global.saved"
return FeedlyTagResourceId(id: id)
}
}
}

View File

@ -10,7 +10,13 @@ import Foundation
struct FeedlyStream: Decodable {
var id: String
var timestamp: Date?
/// Of the most recent entry for this stream (regardless of continuation, newerThan, etc).
var updated: Date?
/// Optional string the continuation id to pass to the next stream call, for pagination.
/// This id guarantees that no entry will be duplicated in a stream (meaning, there is no need to de-duplicate entries returned by this call).
/// If this value is not returned, it means the end of the stream has been reached.
var continuation: String?
var items: [FeedlyEntry]

View File

@ -0,0 +1,18 @@
//
// FeedlyStreamIds.swift
// Account
//
// Created by Kiel Gillard on 18/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyStreamIds: Decodable {
var continuation: String?
var ids: [String]
var isStreamEnd: Bool {
return continuation == nil
}
}

View File

@ -0,0 +1,27 @@
//
// FeedlyCheckpointOperation.swift
// Account
//
// Created by Kiel Gillard on 18/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyCheckpointOperationDelegate: class {
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation)
}
/// Single responsibility is to let the delegate know an instance is executing. The semantics are up to the delegate.
final class FeedlyCheckpointOperation: FeedlyOperation {
weak var checkpointDelegate: FeedlyCheckpointOperationDelegate?
override func main() {
defer { didFinish() }
guard !isCancelled else {
return
}
checkpointDelegate?.feedlyCheckpointOperationDidReachCheckpoint(self)
}
}

View File

@ -9,28 +9,24 @@
import Foundation
/// An operation with a queue of its own.
final class FeedlyCompoundOperation: FeedlyOperation {
final class FeedlyCompoundOperation: FeedlyOperation, FeedlyCheckpointOperationDelegate {
private let operationQueue = OperationQueue()
private var finishOperation: BlockOperation?
private let finishOperation = FeedlyCheckpointOperation()
init(operations: [Operation]) {
assert(!operations.isEmpty)
operationQueue.isSuspended = true
finishOperation = nil
super.init()
let finish = BlockOperation {
self.didFinish()
}
finishOperation = finish
finishOperation.checkpointDelegate = self
for operation in operations {
finish.addDependency(operation)
finishOperation.addDependency(operation)
}
var initialOperations = operations
initialOperations.append(finish)
initialOperations.append(finishOperation)
operationQueue.addOperations(initialOperations, waitUntilFinished: false)
}
@ -48,9 +44,13 @@ final class FeedlyCompoundOperation: FeedlyOperation {
operationQueue.isSuspended = false
}
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
didFinish()
}
func addAnotherOperation(_ operation: Operation) {
guard !isCancelled else { return }
finishOperation?.addDependency(operation)
finishOperation.addDependency(operation)
operationQueue.addOperation(operation)
}

View File

@ -16,13 +16,13 @@ protocol FeedlyCollectionProviding: class {
/// Single responsibility is to get Collections from Feedly.
final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding {
let caller: FeedlyAPICaller
let service: FeedlyGetCollectionsService
let log: OSLog
private(set) var collections = [FeedlyCollection]()
init(caller: FeedlyAPICaller, log: OSLog) {
self.caller = caller
init(service: FeedlyGetCollectionsService, log: OSLog) {
self.service = service
self.log = log
}
@ -34,7 +34,7 @@ final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProv
os_log(.debug, log: log, "Requesting collections.")
caller.getCollections { result in
service.getCollections { result in
switch result {
case .success(let collections):
os_log(.debug, log: self.log, "Received collections: %@.", collections.map { $0.id })

View File

@ -0,0 +1,42 @@
//
// FeedlyGetEntriesOperation.swift
// Account
//
// Created by Kiel Gillard on 28/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/// Single responsibility is to get full entries for the entry identifiers.
final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding {
let account: Account
let service: FeedlyGetEntriesService
let provider: FeedlyEntryIdenifierProviding
init(account: Account, service: FeedlyGetEntriesService, provider: FeedlyEntryIdenifierProviding) {
self.account = account
self.service = service
self.provider = provider
}
private (set) var entries = [FeedlyEntry]()
override func main() {
guard !isCancelled else {
didFinish()
return
}
service.getEntries(for: provider.entryIds) { result in
switch result {
case .success(let entries):
self.entries = entries
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -9,18 +9,21 @@
import Foundation
import RSParser
protocol FeedlyEntryProviding: class {
var resource: FeedlyResourceId { get }
protocol FeedlyEntryProviding {
var entries: [FeedlyEntry] { get }
}
protocol FeedlyParsedItemProviding {
var resource: FeedlyResourceId { get }
var parsedEntries: Set<ParsedItem> { get }
}
protocol FeedlyGetStreamOperationDelegate: class {
func feedlyGetStreamOperation(_ operation: FeedlyGetStreamOperation, didGet stream: FeedlyStream)
protocol FeedlyGetStreamContentsOperationDelegate: class {
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream)
}
/// Single responsibility is to get the stream content of a Collection from Feedly.
final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
struct ResourceProvider: FeedlyResourceProviding {
var resource: FeedlyResourceId
@ -33,7 +36,7 @@ final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
}
var entries: [FeedlyEntry] {
guard let entries = storedStream?.items else {
guard let entries = stream?.items else {
assert(isFinished, "This should only be called when the operation finishes without error.")
assertionFailure("Has this operation been addeded as a dependency on the caller?")
return []
@ -52,7 +55,7 @@ final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
return parsed
}
private var storedStream: FeedlyStream? {
private(set) var stream: FeedlyStream? {
didSet {
storedParsedEntries = nil
}
@ -61,24 +64,24 @@ final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
private var storedParsedEntries: Set<ParsedItem>?
let account: Account
let caller: FeedlyAPICaller
let service: FeedlyGetStreamContentsService
let unreadOnly: Bool?
let newerThan: Date?
let continuation: String?
weak var streamDelegate: FeedlyGetStreamOperationDelegate?
weak var streamDelegate: FeedlyGetStreamContentsOperationDelegate?
init(account: Account, resource: FeedlyResourceId, caller: FeedlyAPICaller, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil) {
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil) {
self.account = account
self.resourceProvider = ResourceProvider(resource: resource)
self.caller = caller
self.service = service
self.continuation = continuation
self.unreadOnly = unreadOnly
self.newerThan = newerThan
}
convenience init(account: Account, resourceProvider: FeedlyResourceProviding, caller: FeedlyAPICaller, newerThan: Date?, unreadOnly: Bool? = nil) {
self.init(account: account, resource: resourceProvider.resource, caller: caller, newerThan: newerThan, unreadOnly: unreadOnly)
convenience init(account: Account, resourceProvider: FeedlyResourceProviding, service: FeedlyGetStreamContentsService, newerThan: Date?, unreadOnly: Bool? = nil) {
self.init(account: account, resource: resourceProvider.resource, service: service, newerThan: newerThan, unreadOnly: unreadOnly)
}
override func main() {
@ -87,12 +90,12 @@ final class FeedlyGetStreamOperation: FeedlyOperation, FeedlyEntryProviding {
return
}
caller.getStream(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
self.storedStream = stream
self.stream = stream
self.streamDelegate?.feedlyGetStreamOperation(self, didGet: stream)
self.streamDelegate?.feedlyGetStreamContentsOperation(self, didGetContentsOf: stream)
self.didFinish()

View File

@ -0,0 +1,73 @@
//
// FeedlyGetStreamIdsOperation.swift
// Account
//
// Created by Kiel Gillard on 18/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
protocol FeedlyEntryIdenifierProviding: class {
var resource: FeedlyResourceId { get }
var entryIds: Set<String> { get }
}
protocol FeedlyGetStreamIdsOperationDelegate: class {
func feedlyGetStreamIdsOperation(_ operation: FeedlyGetStreamIdsOperation, didGet streamIds: FeedlyStreamIds)
}
/// Single responsibility is to get the stream ids from Feedly.
final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdenifierProviding, FeedlyUnreadEntryIdProviding {
var entryIds: Set<String> {
guard let ids = streamIds?.ids else {
assert(isFinished, "This should only be called when the operation finishes without error.")
assertionFailure("Has this operation been addeded as a dependency on the caller?")
return []
}
return Set(ids)
}
private(set) var streamIds: FeedlyStreamIds?
let account: Account
let service: FeedlyGetStreamIdsService
let continuation: String?
let resource: FeedlyResourceId
let unreadOnly: Bool?
let newerThan: Date?
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, continuation: String? = nil, newerThan: Date? = nil, unreadOnly: Bool?) {
self.account = account
self.resource = resource
self.service = service
self.continuation = continuation
self.newerThan = newerThan
self.unreadOnly = unreadOnly
}
weak var streamIdsDelegate: FeedlyGetStreamIdsOperationDelegate?
override func main() {
guard !isCancelled else {
didFinish()
return
}
service.getStreamIds(for: resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
self.streamIds = stream
self.streamIdsDelegate?.feedlyGetStreamIdsOperation(self, didGet: stream)
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -20,7 +20,6 @@ protocol FeedlyFeedsAndFoldersProviding {
/// Single responsibility is accurately reflect Collections from Feedly as Folders.
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCollectionsAndFoldersProviding, FeedlyFeedsAndFoldersProviding {
let caller: FeedlyAPICaller
let account: Account
let collectionsProvider: FeedlyCollectionProviding
let log: OSLog
@ -28,10 +27,9 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCo
private(set) var collectionsAndFolders = [(FeedlyCollection, Folder)]()
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller, log: OSLog) {
init(account: Account, collectionsProvider: FeedlyCollectionProviding, log: OSLog) {
self.collectionsProvider = collectionsProvider
self.account = account
self.caller = caller
self.log = log
}

View File

@ -45,7 +45,7 @@ class FeedlyOperation: Operation {
return isExecutingOperation
}
var isExecutingOperation = false {
private var isExecutingOperation = false {
willSet {
willChangeValue(for: \.isExecuting)
}

View File

@ -18,7 +18,7 @@ protocol FeedlyParsedItemsByFeedProviding {
/// Single responsibility is to group articles by their feeds.
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding {
private let account: Account
private let entryProvider: FeedlyEntryProviding
private let parsedItemProvider: FeedlyParsedItemProviding
private let log: OSLog
var parsedItemsKeyedByFeedId: [String : Set<ParsedItem>] {
@ -27,14 +27,14 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
}
var providerName: String {
return entryProvider.resource.id
return parsedItemProvider.resource.id
}
private var itemsKeyedByFeedId = [String: Set<ParsedItem>]()
init(account: Account, entryProvider: FeedlyEntryProviding, log: OSLog) {
init(account: Account, parsedItemProvider: FeedlyParsedItemProviding, log: OSLog) {
self.account = account
self.entryProvider = entryProvider
self.parsedItemProvider = parsedItemProvider
self.log = log
}
@ -43,7 +43,7 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
guard !isCancelled else { return }
let items = entryProvider.parsedEntries
let items = parsedItemProvider.parsedEntries
var dict = [String: Set<ParsedItem>](minimumCapacity: items.count)
for item in items {
@ -61,7 +61,7 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
guard !isCancelled else { return }
}
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, entryProvider.resource.id)
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.resource.id)
itemsKeyedByFeedId = dict
}

View File

@ -10,7 +10,7 @@ import Foundation
import os.log
protocol FeedlyRequestStreamsOperationDelegate: class {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetStreamOperation)
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetStreamContentsOperation)
}
/// Single responsibility is to create one stream request operation for one Feedly collection.
@ -20,15 +20,15 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
weak var queueDelegate: FeedlyRequestStreamsOperationDelegate?
let collectionsProvider: FeedlyCollectionProviding
let caller: FeedlyAPICaller
let service: FeedlyGetStreamContentsService
let account: Account
let log: OSLog
let newerThan: Date?
let unreadOnly: Bool?
init(account: Account, collectionsProvider: FeedlyCollectionProviding, newerThan: Date?, unreadOnly: Bool?, caller: FeedlyAPICaller, log: OSLog) {
init(account: Account, collectionsProvider: FeedlyCollectionProviding, newerThan: Date?, unreadOnly: Bool?, service: FeedlyGetStreamContentsService, log: OSLog) {
self.account = account
self.caller = caller
self.service = service
self.collectionsProvider = collectionsProvider
self.newerThan = newerThan
self.unreadOnly = unreadOnly
@ -46,9 +46,9 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
for collection in collectionsProvider.collections {
let resource = FeedlyCategoryResourceId(id: collection.id)
let operation = FeedlyGetStreamOperation(account: account,
let operation = FeedlyGetStreamContentsOperation(account: account,
resource: resource,
caller: caller,
service: service,
newerThan: newerThan,
unreadOnly: unreadOnly)
queueDelegate?.feedlyRequestStreamsOperation(self, enqueue: operation)

View File

@ -11,15 +11,15 @@ import Articles
import SyncDatabase
import os.log
/// Single responsibility is to update or ensure articles from the entry provider are the only starred articles.
/// Single responsibility is to take changes to statuses of articles locally and apply them to the corresponding the articles remotely.
final class FeedlySendArticleStatusesOperation: FeedlyOperation {
private let database: SyncDatabase
private let log: OSLog
private let caller: FeedlyAPICaller
private let service: FeedlyMarkArticlesService
init(database: SyncDatabase, caller: FeedlyAPICaller, log: OSLog) {
init(database: SyncDatabase, service: FeedlyMarkArticlesService, log: OSLog) {
self.database = database
self.caller = caller
self.service = service
self.log = log
}
@ -34,7 +34,7 @@ final class FeedlySendArticleStatusesOperation: FeedlyOperation {
let pending = database.selectForProcessing()
let statuses: [(status: ArticleStatus.Key, flag: Bool, action: FeedlyAPICaller.MarkAction)] = [
let statuses: [(status: ArticleStatus.Key, flag: Bool, action: FeedlyMarkAction)] = [
(.read, false, .unread),
(.read, true, .read),
(.starred, true, .saved),
@ -52,14 +52,18 @@ final class FeedlySendArticleStatusesOperation: FeedlyOperation {
let ids = Set(articleIds.map { $0.articleID })
let database = self.database
group.enter()
caller.mark(ids, as: pairing.action) { result in
service.mark(ids, as: pairing.action) { result in
assert(Thread.isMainThread)
switch result {
case .success:
database.deleteSelectedForProcessing(Array(ids))
database.deleteSelectedForProcessing(Array(ids)) {
group.leave()
}
case .failure:
database.resetSelectedForProcessing(Array(ids))
database.resetSelectedForProcessing(Array(ids)) {
group.leave()
}
}
group.leave()
}
}

View File

@ -9,15 +9,20 @@
import Foundation
import os.log
/// Single responsibility is to update or ensure articles from the entry provider are the only starred articles.
protocol FeedlyStarredEntryIdProviding {
var entryIds: Set<String> { get }
}
/// Single responsibility is to associate a starred status for ingested and remote
/// articles identfied by the provided identifiers *for the entire account.*
final class FeedlySetStarredArticlesOperation: FeedlyOperation {
private let account: Account
private let allStarredEntriesProvider: FeedlyEntryProviding
private let allStarredEntryIdsProvider: FeedlyStarredEntryIdProviding
private let log: OSLog
init(account: Account, allStarredEntriesProvider: FeedlyEntryProviding, log: OSLog) {
init(account: Account, allStarredEntryIdsProvider: FeedlyStarredEntryIdProviding, log: OSLog) {
self.account = account
self.allStarredEntriesProvider = allStarredEntriesProvider
self.allStarredEntryIdsProvider = allStarredEntryIdsProvider
self.log = log
}
@ -28,7 +33,7 @@ final class FeedlySetStarredArticlesOperation: FeedlyOperation {
return
}
let remoteStarredArticleIds = Set(allStarredEntriesProvider.entries.map { $0.id })
let remoteStarredArticleIds = allStarredEntryIdsProvider.entryIds
let localStarredArticleIDs = account.fetchStarredArticleIDs()
// Mark articles as starred

View File

@ -0,0 +1,61 @@
//
// FeedlySetUnreadArticlesOperation.swift
// Account
//
// Created by Kiel Gillard on 25/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
protocol FeedlyUnreadEntryIdProviding {
var entryIds: Set<String> { get }
}
/// Single responsibility is to associate a read status for ingested and remote articles
/// where the provided article identifers identify the unread articles *for the entire account.*
final class FeedlySetUnreadArticlesOperation: FeedlyOperation {
private let account: Account
private let allUnreadIdsProvider: FeedlyUnreadEntryIdProviding
private let log: OSLog
init(account: Account, allUnreadIdsProvider: FeedlyUnreadEntryIdProviding, log: OSLog) {
self.account = account
self.allUnreadIdsProvider = allUnreadIdsProvider
self.log = log
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
let remoteUnreadArticleIds = allUnreadIdsProvider.entryIds
//Set(entries.filter { $0.unread }.map { $0.id })
let localUnreadArticleIds = account.fetchUnreadArticleIDs()
// Mark articles as unread
let deltaUnreadArticleIds = remoteUnreadArticleIds.subtracting(localUnreadArticleIds)
let markUnreadArticles = account.fetchArticles(.articleIDs(deltaUnreadArticleIds))
account.update(markUnreadArticles, statusKey: .read, flag: false)
// Save any unread statuses for articles we haven't yet received
let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID })
let missingUnreadArticleIDs = deltaUnreadArticleIds.subtracting(markUnreadArticleIDs)
account.ensureStatuses(missingUnreadArticleIDs, true, .read, false)
// Mark articles as read
let deltaReadArticleIds = localUnreadArticleIds.subtracting(remoteUnreadArticleIds)
let markReadArticles = account.fetchArticles(.articleIDs(deltaReadArticleIds))
account.update(markReadArticles, statusKey: .read, flag: true)
// Save any read statuses for articles we haven't yet received
let markReadArticleIDs = Set(markReadArticles.map { $0.articleID })
let missingReadArticleIDs = deltaReadArticleIds.subtracting(markReadArticleIDs)
account.ensureStatuses(missingReadArticleIDs, true, .read, true)
didFinish()
}
}

View File

@ -0,0 +1,137 @@
//
// FeedlySyncAllOperation.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
import SyncDatabase
/// Single responsibility is to 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 {
private let operationQueue: OperationQueue
private let log: OSLog
let syncUUID: UUID
var syncCompletionHandler: ((Result<Void, Error>) -> ())?
init(account: Account, credentials: Credentials, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredArticlesService: FeedlyGetStreamContentsService, database: SyncDatabase, log: OSLog) {
self.syncUUID = UUID()
self.log = log
self.operationQueue = OperationQueue()
self.operationQueue.isSuspended = true
super.init()
// Send any read/unread/starred article statuses to Feedly before anything else.
let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, service: markArticlesService, log: log)
sendArticleStatuses.delegate = self
self.operationQueue.addOperation(sendArticleStatuses)
// Get each page of unread article ids in the global.all stream for the last 31 days (nil = Feedly API default).
let getUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: getUnreadService, newerThan: nil, log: log)
getUnread.delegate = self
getUnread.addDependency(sendArticleStatuses)
self.operationQueue.addOperation(getUnread)
// Get all the Collections the user has.
let getCollections = FeedlyGetCollectionsOperation(service: getCollectionsService, log: log)
getCollections.delegate = self
getCollections.addDependency(sendArticleStatuses)
self.operationQueue.addOperation(getCollections)
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: log)
mirrorCollectionsAsFolders.delegate = self
mirrorCollectionsAsFolders.addDependency(getCollections)
self.operationQueue.addOperation(mirrorCollectionsAsFolders)
// Ensure feeds are created and grouped by their folders.
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: mirrorCollectionsAsFolders, log: log)
createFeedsOperation.delegate = self
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
self.operationQueue.addOperation(createFeedsOperation)
// Get each page of the global.all stream until we get either the content from the last sync or the last 31 days.
let getStreamContents = FeedlySyncStreamContentsOperation(account: account, credentials: credentials, service: getStreamContentsService, newerThan: lastSuccessfulFetchStartDate, log: log)
getStreamContents.delegate = self
getStreamContents.addDependency(getCollections)
getStreamContents.addDependency(getUnread)
getStreamContents.addDependency(createFeedsOperation)
self.operationQueue.addOperation(getStreamContents)
// Get each and every starred article.
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, service: getStarredArticlesService, log: log)
syncStarred.addDependency(createFeedsOperation)
self.operationQueue.addOperation(syncStarred)
// Once this operation's dependencies, their dependencies etc finish, we can finish.
let finishOperation = FeedlyCheckpointOperation()
finishOperation.checkpointDelegate = self
finishOperation.addDependency(getStreamContents)
finishOperation.addDependency(syncStarred)
self.operationQueue.addOperation(finishOperation)
}
convenience init(account: Account, credentials: Credentials, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, log: OSLog) {
let newerThan: Date? = {
if let date = lastSuccessfulFetchStartDate {
return date
} else {
return Calendar.current.date(byAdding: .day, value: -31, to: Date())
}
}()
self.init(account: account, credentials: credentials, lastSuccessfulFetchStartDate: newerThan, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredArticlesService: caller, database: database, log: log)
}
override func cancel() {
os_log(.debug, log: log, "Cancelling sync %{public}@", syncUUID.uuidString)
self.operationQueue.cancelAllOperations()
syncCompletionHandler?(.failure(URLError(.cancelled)))
syncCompletionHandler = nil
self.didFinish()
}
override func main() {
guard !isCancelled else {
return
}
os_log(.debug, log: log, "Starting sync %{public}@", syncUUID.uuidString)
operationQueue.isSuspended = false
}
}
extension FeedlySyncAllOperation: FeedlyCheckpointOperationDelegate {
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
assert(Thread.isMainThread)
os_log(.debug, log: self.log, "Sync completed: %{public}@", syncUUID.uuidString)
syncCompletionHandler?(.success(()))
syncCompletionHandler = nil
didFinish()
}
}
extension FeedlySyncAllOperation: FeedlyOperationDelegate {
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
assert(Thread.isMainThread)
os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", operation, error.localizedDescription)
syncCompletionHandler?(.failure(error))
syncCompletionHandler = nil
cancel()
}
}

View File

@ -10,16 +10,17 @@ import Foundation
import os.log
import RSParser
final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamOperationDelegate {
final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate {
private let account: Account
private let operationQueue: OperationQueue
private let caller: FeedlyAPICaller
private let service: FeedlyGetStreamContentsService
private let log: OSLog
private let setStatuses: FeedlySetStarredArticlesOperation
private let finishOperation: FeedlyCheckpointOperation
/// Buffers every starred/saved entry from every page.
private class StarredEntryProvider: FeedlyEntryProviding {
private class StarredEntryProvider: FeedlyEntryProviding, FeedlyStarredEntryIdProviding, FeedlyParsedItemProviding {
var resource: FeedlyResourceId
private(set) var parsedEntries = Set<ParsedItem>()
@ -29,37 +30,46 @@ final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperation
self.resource = resource
}
func addEntries(from provider: FeedlyEntryProviding) {
func addEntries(from provider: FeedlyEntryProviding & FeedlyParsedItemProviding) {
entries.append(contentsOf: provider.entries)
parsedEntries.formUnion(provider.parsedEntries)
}
var entryIds: Set<String> {
return Set(entries.map { $0.id })
}
}
private let entryProvider: StarredEntryProvider
init(account: Account, credentials: Credentials, caller: FeedlyAPICaller, log: OSLog) {
convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, log: OSLog) {
let saved = FeedlyTagResourceId.Global.saved(for: credentials.username)
self.init(account: account, resource: saved, service: service, log: log)
}
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, log: OSLog) {
self.account = account
self.caller = caller
self.service = service
self.operationQueue = OperationQueue()
self.operationQueue.isSuspended = true
self.finishOperation = FeedlyCheckpointOperation()
self.log = log
let saved = FeedlyTagResourceId.saved(for: credentials.username)
let provider = StarredEntryProvider(resource: saved)
let provider = StarredEntryProvider(resource: resource)
self.entryProvider = provider
self.setStatuses = FeedlySetStarredArticlesOperation(account: account,
allStarredEntriesProvider: provider,
allStarredEntryIdsProvider: provider,
log: log)
super.init()
let getFirstPage = FeedlyGetStreamOperation(account: account,
resource: saved,
caller: caller,
let getFirstPage = FeedlyGetStreamContentsOperation(account: account,
resource: resource,
service: service,
newerThan: nil)
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
entryProvider: provider,
parsedItemProvider: provider,
log: log)
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
@ -78,12 +88,7 @@ final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperation
updateAccount.addDependency(organiseByFeed)
updateAccount.delegate = self
let finishOperation = BlockOperation { [weak self] in
DispatchQueue.main.async {
self?.didFinish()
}
}
finishOperation.checkpointDelegate = self
finishOperation.addDependency(updateAccount)
let operations = [getFirstPage, setStatuses, organiseByFeed, updateAccount, finishOperation]
@ -104,7 +109,7 @@ final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperation
operationQueue.isSuspended = false
}
func feedlyGetStreamOperation(_ operation: FeedlyGetStreamOperation, didGet stream: FeedlyStream) {
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) {
entryProvider.addEntries(from: operation)
os_log(.debug, log: log, "Collecting %i items from %@", stream.items.count, stream.id)
@ -112,9 +117,9 @@ final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperation
return
}
let nextPageOperation = FeedlyGetStreamOperation(account: operation.account,
let nextPageOperation = FeedlyGetStreamContentsOperation(account: operation.account,
resource: operation.resource,
caller: operation.caller,
service: operation.service,
continuation: continuation,
newerThan: operation.newerThan)
nextPageOperation.delegate = self
@ -124,7 +129,12 @@ final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperation
operationQueue.addOperation(nextPageOperation)
}
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
didFinish()
}
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
os_log(.debug, log: log, "%{public}@ failing and cancelling other operations because %{public}@.", operation, error.localizedDescription)
operationQueue.cancelAllOperations()
didFinish(error)
}

View File

@ -0,0 +1,114 @@
//
// FeedlySyncStreamContentsOperation.swift
// Account
//
// Created by Kiel Gillard on 17/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
import RSParser
final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate {
private let account: Account
private let resource: FeedlyResourceId
private let operationQueue: OperationQueue
private let service: FeedlyGetStreamContentsService
private let newerThan: Date?
private let log: OSLog
private let finishOperation: FeedlyCheckpointOperation
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) {
self.account = account
self.resource = resource
self.service = service
self.operationQueue = OperationQueue()
self.operationQueue.isSuspended = true
self.newerThan = newerThan
self.log = log
self.finishOperation = FeedlyCheckpointOperation()
super.init()
self.operationQueue.addOperation(self.finishOperation)
self.finishOperation.checkpointDelegate = self
enqueueOperations(for: nil)
}
convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) {
let all = FeedlyCategoryResourceId.Global.all(for: credentials.username)
self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log)
}
override func cancel() {
operationQueue.cancelAllOperations()
super.cancel()
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
operationQueue.isSuspended = false
}
func enqueueOperations(for continuation: String?) {
os_log(.debug, log: log, "Requesting page for %@", resource.id)
let operations = pageOperations(for: continuation)
operationQueue.addOperations(operations, waitUntilFinished: false)
}
func pageOperations(for continuation: String?) -> [Operation] {
let getPage = FeedlyGetStreamContentsOperation(account: account,
resource: resource,
service: service,
continuation: continuation,
newerThan: newerThan)
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
parsedItemProvider: getPage,
log: log)
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
organisedItemsProvider: organiseByFeed,
log: log)
getPage.delegate = self
getPage.streamDelegate = self
organiseByFeed.addDependency(getPage)
organiseByFeed.delegate = self
updateAccount.addDependency(organiseByFeed)
updateAccount.delegate = self
finishOperation.addDependency(updateAccount)
return [getPage, organiseByFeed, updateAccount]
}
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) {
os_log(.debug, log: log, "Ingesting %i items from %@", stream.items.count, stream.id)
guard let continuation = stream.continuation else {
os_log(.debug, log: log, "Reached end of stream for %@", stream.id)
return
}
enqueueOperations(for: continuation)
}
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
os_log(.debug, log: log, "Completed ingesting items from %@", resource.id)
didFinish()
}
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
operationQueue.cancelAllOperations()
didFinish(error)
}
}

View File

@ -0,0 +1,122 @@
//
// FeedlySyncUnreadStatusesOperation.swift
// Account
//
// Created by Kiel Gillard on 18/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
import RSParser
/// Makes one or more requests to get the complete set of unread article ids to update the status of those articles *for the entire account.*
final class FeedlySyncUnreadStatusesOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamIdsOperationDelegate, FeedlyCheckpointOperationDelegate {
private let account: Account
private let resource: FeedlyResourceId
private let operationQueue: OperationQueue
private let service: FeedlyGetStreamIdsService
private let log: OSLog
/// Buffers every unread article id from every page of the resource's stream.
private class UnreadEntryIdsProvider: FeedlyUnreadEntryIdProviding {
let resource: FeedlyResourceId
private(set) var entryIds = Set<String>()
init(resource: FeedlyResourceId) {
self.resource = resource
}
func addEntryIds(from provider: FeedlyEntryIdenifierProviding) {
entryIds.formUnion(provider.entryIds)
}
}
private let unreadEntryIdsProvider: UnreadEntryIdsProvider
private let setStatuses: FeedlySetUnreadArticlesOperation
convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
let resource = FeedlyCategoryResourceId.Global.all(for: credentials.username)
self.init(account: account, resource: resource, service: service, newerThan: newerThan, log: log)
}
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
self.account = account
self.resource = resource
self.service = service
self.operationQueue = OperationQueue()
self.operationQueue.isSuspended = true
self.log = log
let provider = UnreadEntryIdsProvider(resource: resource)
self.unreadEntryIdsProvider = provider
self.setStatuses = FeedlySetUnreadArticlesOperation(account: account,
allUnreadIdsProvider: unreadEntryIdsProvider,
log: log)
super.init()
let getFirstPageOfUnreadIds = FeedlyGetStreamIdsOperation(account: account,
resource: resource,
service: service,
newerThan: nil,
unreadOnly: true)
getFirstPageOfUnreadIds.delegate = self
getFirstPageOfUnreadIds.streamIdsDelegate = self
setStatuses.addDependency(getFirstPageOfUnreadIds)
setStatuses.delegate = self
let finishOperation = FeedlyCheckpointOperation()
finishOperation.checkpointDelegate = self
finishOperation.addDependency(setStatuses)
let operations = [getFirstPageOfUnreadIds, setStatuses, finishOperation]
operationQueue.addOperations(operations, waitUntilFinished: false)
}
override func cancel() {
operationQueue.cancelAllOperations()
super.cancel()
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
operationQueue.isSuspended = false
}
func feedlyGetStreamIdsOperation(_ operation: FeedlyGetStreamIdsOperation, didGet streamIds: FeedlyStreamIds) {
os_log(.debug, log: log, "Collecting %i unread article ids from %@", streamIds.ids.count, resource.id)
unreadEntryIdsProvider.addEntryIds(from: operation)
guard let continuation = streamIds.continuation else {
return
}
let nextPageOperation = FeedlyGetStreamIdsOperation(account: operation.account,
resource: operation.resource,
service: operation.service,
continuation: continuation,
newerThan: operation.newerThan,
unreadOnly: operation.unreadOnly)
nextPageOperation.delegate = self
nextPageOperation.streamIdsDelegate = self
setStatuses.addDependency(nextPageOperation)
operationQueue.addOperation(nextPageOperation)
}
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
didFinish()
}
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
operationQueue.cancelAllOperations()
didFinish(error)
}
}

View File

@ -1,49 +0,0 @@
//
// FeedlyRefreshStreamEntriesStatusOperation.swift
// Account
//
// Created by Kiel Gillard on 25/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
/// Single responsibility is to update the read status of articles stored locally with the unread status of the entries in a Collection's stream from Feedly.
final class FeedlyRefreshStreamEntriesStatusOperation: FeedlyOperation {
private let account: Account
private let entryProvider: FeedlyEntryProviding
private let log: OSLog
init(account: Account, entryProvider: FeedlyEntryProviding, log: OSLog) {
self.account = account
self.entryProvider = entryProvider
self.log = log
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
let entries = entryProvider.entries
let unreadArticleIds = Set(entries.filter { $0.unread }.map { $0.id })
// Mark articles as unread
let currentUnreadArticleIDs = account.fetchUnreadArticleIDs()
let deltaUnreadArticleIDs = unreadArticleIds.subtracting(currentUnreadArticleIDs)
let markUnreadArticles = account.fetchArticles(.articleIDs(deltaUnreadArticleIDs))
account.update(markUnreadArticles, statusKey: .read, flag: false)
let readAritcleIds = Set(entries.filter { !$0.unread }.map { $0.id })
let deltaReadArticleIDs = currentUnreadArticleIDs.intersection(readAritcleIds)
let markReadArticles = account.fetchArticles(.articleIDs(deltaReadArticleIDs))
account.update(markReadArticles, statusKey: .read, flag: true)
// os_log(.debug, log: log, "\"%@\" - updated %i UNREAD and %i read article(s).", collection.label, unreadArticleIds.count, markReadArticles.count)
didFinish()
}
}

View File

@ -1,188 +0,0 @@
//
// FeedlySyncStrategy.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
import SyncDatabase
final class FeedlySyncStrategy {
let account: Account
let caller: FeedlyAPICaller
let operationQueue: OperationQueue
let database: SyncDatabase
let log: OSLog
init(account: Account, caller: FeedlyAPICaller, database: SyncDatabase, log: OSLog) {
self.account = account
self.caller = caller
self.operationQueue = OperationQueue()
self.log = log
self.database = database
}
func cancel() {
os_log(.debug, log: log, "Cancelling all operations.")
self.operationQueue.cancelAllOperations()
}
private var startSyncCompletionHandler: ((Result<Void, Error>) -> ())?
private var newerThan: Date? {
if let date = account.metadata.lastArticleFetch {
return date
} else {
return Calendar.current.date(byAdding: .day, value: -31, to: Date())
}
}
/// The truth is in the cloud.
func startSync(completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard operationQueue.operationCount == 0 else {
os_log(.debug, log: log, "Reqeusted start sync but ignored because a sync is already in progress.")
completionHandler(.success(()))
return
}
guard let credentials = caller.credentials else {
completionHandler(.failure(FeedlyAccountDelegateError.notLoggedIn))
return
}
let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, caller: caller, log: log)
sendArticleStatuses.delegate = self
// Since the truth is in the cloud, everything hinges of what Collections the user has.
let getCollections = FeedlyGetCollectionsOperation(caller: caller, log: log)
getCollections.delegate = self
getCollections.addDependency(sendArticleStatuses)
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account,
collectionsProvider: getCollections,
caller: caller,
log: log)
mirrorCollectionsAsFolders.delegate = self
mirrorCollectionsAsFolders.addDependency(getCollections)
// Ensure feeds are created and grouped by their folders.
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account,
feedsAndFoldersProvider: mirrorCollectionsAsFolders,
log: log)
createFeedsOperation.delegate = self
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
// Get the streams for each Collection. It will call back to enqueue more operations.
let getCollectionStreams = FeedlyRequestStreamsOperation(account: account,
collectionsProvider: getCollections,
newerThan: newerThan,
unreadOnly: false,
caller: caller,
log: log)
getCollectionStreams.delegate = self
getCollectionStreams.queueDelegate = self
getCollectionStreams.addDependency(getCollections)
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, caller: caller, log: log)
syncStarred.addDependency(getCollections)
syncStarred.addDependency(mirrorCollectionsAsFolders)
syncStarred.addDependency(createFeedsOperation)
// Last operation to perform, which should be dependent on any other operation added to the queue.
let syncId = UUID().uuidString
let lastArticleFetchDate = Date()
let completionOperation = BlockOperation { [weak self] in
DispatchQueue.main.async {
if let self = self {
self.account.metadata.lastArticleFetch = lastArticleFetchDate
os_log(.debug, log: self.log, "Sync completed: %@", syncId)
self.startSyncCompletionHandler = nil
}
completionHandler(.success(()))
}
}
completionOperation.addDependency(sendArticleStatuses)
completionOperation.addDependency(getCollections)
completionOperation.addDependency(mirrorCollectionsAsFolders)
completionOperation.addDependency(createFeedsOperation)
completionOperation.addDependency(getCollectionStreams)
completionOperation.addDependency(syncStarred)
finalOperation = completionOperation
startSyncCompletionHandler = completionHandler
let minimumOperations = [sendArticleStatuses,
getCollections,
mirrorCollectionsAsFolders,
createFeedsOperation,
getCollectionStreams,
syncStarred,
completionOperation]
operationQueue.addOperations(minimumOperations, waitUntilFinished: false)
os_log(.debug, log: log, "Sync started: %@", syncId)
}
private weak var finalOperation: Operation?
}
extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue streamOperation: FeedlyGetStreamOperation) {
streamOperation.delegate = self
// os_log(.debug, log: log, "Requesting stream for collection \"%@\"", streamOperation.collection.label)
// Group the stream's content by feed.
let groupItemsByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
entryProvider: streamOperation,
log: log)
groupItemsByFeed.delegate = self
groupItemsByFeed.addDependency(streamOperation)
// Update the account with the articles for the feeds in the stream.
let updateOperation = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
organisedItemsProvider: groupItemsByFeed,
log: log)
updateOperation.delegate = self
updateOperation.addDependency(groupItemsByFeed)
// Once the articles are in the account, ensure they have the correct status
let ensureUnreadOperation = FeedlyRefreshStreamEntriesStatusOperation(account: account,
entryProvider: streamOperation,
log: log)
ensureUnreadOperation.delegate = self
ensureUnreadOperation.addDependency(updateOperation)
// Sync completes successfully when the account has been updated with all the parsedd entries from the stream.
if let operation = finalOperation {
operation.addDependency(ensureUnreadOperation)
}
let operations = [streamOperation, groupItemsByFeed, updateOperation, ensureUnreadOperation]
operationQueue.addOperations(operations, waitUntilFinished: false)
}
}
extension FeedlySyncStrategy: FeedlyOperationDelegate {
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
os_log(.debug, log: log, "%@ failed so sync failed with error %@", operation, error.localizedDescription)
cancel()
startSyncCompletionHandler?(.failure(error))
startSyncCompletionHandler = nil
}
}

View File

@ -0,0 +1,13 @@
//
// FeedlyGetCollectionsService.swift
// Account
//
// Created by Kiel Gillard on 21/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyGetCollectionsService: class {
func getCollections(completionHandler: @escaping (Result<[FeedlyCollection], Error>) -> ())
}

View File

@ -0,0 +1,13 @@
//
// FeedlyGetEntriesService.swift
// Account
//
// Created by Kiel Gillard on 28/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyGetEntriesService: class {
func getEntries(for ids: Set<String>, completionHandler: @escaping (Result<[FeedlyEntry], Error>) -> ())
}

View File

@ -0,0 +1,13 @@
//
// FeedlyGetStreamContentsService.swift
// Account
//
// Created by Kiel Gillard on 21/10/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyGetStreamContentsService: class {
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ())
}

Some files were not shown because too many files have changed in this diff Show More