Merge branch 'master' into feature/feed-wrangler

# Conflicts:
#	Frameworks/Account/Account.swift
#	Frameworks/Account/Account.xcodeproj/project.pbxproj
#	NetNewsWire.xcodeproj/project.pbxproj
#	submodules/RSCore
This commit is contained in:
Jonathan Bennett 2019-11-20 14:01:04 -05:00
commit b4a862d207
355 changed files with 89315 additions and 5653 deletions

View File

@ -49,4 +49,14 @@ jobs:
SCHEME: ${{ matrix.run-config['scheme'] }}
DESTINATION: ${{ matrix.run-config['destination'] }}
run: buildscripts/ci-build.sh
run: buildscripts/ci-build.sh
- name: Notify Slack
uses: 8398a7/action-slack@v2.4.2
with:
status: ${{ job.status }}
author_name: GitHub Actions CI
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure()

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>NetNewsWire Betas</title>
<link>https://ranchero.com/downloads/netnewswire-beta.xml</link>
<description>Most recent NetNewsWire changes with links to updates.</description>
<language>en</language>
<item>
<title>NetNewsWire 5.1d2</title>
<description><![CDATA[
<p>Testing Sparkle. Does it work?</p>
]]>
</description>
<pubDate>Tue, 29 Oct 2019 22:35:00 -0700</pubDate>
<enclosure url="https://ranchero.com/downloads/NetNewsWire5.1d2.zip" sparkle:version="3002" sparkle:shortVersionString="5.1d2" length="5391151" type="application/zip" />
<sparkle:minimumSystemVersion>10.15.0</sparkle:minimumSystemVersion>
</item>
</channel>
</rss>

View File

@ -36,7 +36,7 @@ set totalFeeds to 0
tell application "NetNewsWire"
set allAccounts to every account
repeat with nthAccount in allAccounts
set allFeeds to every feed of nthAccount
set allFeeds to every webFeed of nthAccount
repeat with nthFeed in allFeeds
set feedname to name of nthFeed
set articleCount to count (get every article of nthFeed)

View File

@ -38,7 +38,7 @@ set safariWindow to missing value
tell application "NetNewsWire"
set allAccounts to every account
repeat with nthAccount in allAccounts
set allFeeds to every feed of nthAccount
set allFeeds to every webFeed of nthAccount
repeat with nthFeed in allFeeds
set starredArticles to (get every article of nthFeed where starred is true)
repeat with nthArticle in starredArticles

View File

@ -29,7 +29,7 @@ public extension Notification.Name {
static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles")
static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange")
static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange")
static let FeedMetadataDidChange = Notification.Name(rawValue: "FeedMetadataDidChange")
static let WebFeedMetadataDidChange = Notification.Name(rawValue: "WebFeedMetadataDidChange")
}
public enum AccountType: Int {
@ -48,7 +48,7 @@ public enum FetchType {
case unread
case today
case unreadForFolder(Folder)
case feed(Feed)
case webFeed(WebFeed)
case articleIDs(Set<String>)
case search(String)
case searchWithArticleIDs(String, Set<String>)
@ -62,7 +62,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
public static let updatedArticles = "updatedArticles" // AccountDidDownloadArticles
public static let statuses = "statuses" // StatusesDidChange
public static let articles = "articles" // StatusesDidChange
public static let feeds = "feeds" // AccountDidDownloadArticles, StatusesDidChange
public static let webFeeds = "webFeeds" // AccountDidDownloadArticles, StatusesDidChange
}
public static let defaultLocalAccountName: String = {
@ -126,15 +126,23 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
public var topLevelFeeds = Set<Feed>()
public var topLevelWebFeeds = Set<WebFeed>()
public var folders: Set<Folder>? = Set<Folder>()
private var feedDictionaryNeedsUpdate = true
private var _idToFeedDictionary = [String: Feed]()
var idToFeedDictionary: [String: Feed] {
if feedDictionaryNeedsUpdate {
rebuildFeedDictionaries()
public var sortedFolders: [Folder]? {
if let folders = folders {
return Array(folders).sorted(by: { $0.nameForDisplay < $1.nameForDisplay })
}
return _idToFeedDictionary
return nil
}
private var webFeedDictionaryNeedsUpdate = true
private var _idToWebFeedDictionary = [String: WebFeed]()
var idToWebFeedDictionary: [String: WebFeed] {
if webFeedDictionaryNeedsUpdate {
rebuildWebFeedDictionaries()
}
return _idToWebFeedDictionary
}
var username: String? {
@ -169,8 +177,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
private var unreadCounts = [String: Int]() // [feedID: Int]
private var _flattenedFeeds = Set<Feed>()
private var flattenedFeedsNeedUpdate = true
private var _flattenedWebFeeds = Set<WebFeed>()
private var flattenedWebFeedsNeedUpdate = true
private lazy var opmlFile = OPMLFile(filename: (dataFolder as NSString).appendingPathComponent("Subscriptions.opml"), account: self)
private lazy var metadataFile = AccountMetadataFile(filename: (dataFolder as NSString).appendingPathComponent("Settings.plist"), account: self)
@ -180,9 +188,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
private lazy var feedMetadataFile = FeedMetadataFile(filename: (dataFolder as NSString).appendingPathComponent("FeedMetadata.plist"), account: self)
typealias FeedMetadataDictionary = [String: FeedMetadata]
var feedMetadata = FeedMetadataDictionary()
private lazy var webFeedMetadataFile = WebFeedMetadataFile(filename: (dataFolder as NSString).appendingPathComponent("FeedMetadata.plist"), account: self)
typealias WebFeedMetadataDictionary = [String: WebFeedMetadata]
var webFeedMetadata = WebFeedMetadataDictionary()
var startingUp = true
@ -225,9 +233,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
case .freshRSS:
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
case .feedly:
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport)
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment)
case .feedWrangler:
self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport)
default:
return nil
}
@ -261,10 +270,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
metadataFile.load()
feedMetadataFile.load()
webFeedMetadataFile.load()
opmlFile.load()
DispatchQueue.main.async {
self.database.cleanupDatabaseAtStartup(subscribedToWebFeedIDs: self.flattenedWebFeeds().webFeedIDs())
self.fetchAllUnreadCounts()
}
@ -311,7 +321,16 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
public static func oauthAuthorizationCodeGrantRequest(for type: AccountType, client: OAuthAuthorizationClient) -> URLRequest {
internal static func oauthAuthorizationClient(for type: AccountType) -> OAuthAuthorizationClient {
switch type {
case .feedly:
return FeedlyAccountDelegate.environment.oauthAuthorizationClient
default:
fatalError("\(type) is not a client for OAuth authorization code granting.")
}
}
public static func oauthAuthorizationCodeGrantRequest(for type: AccountType) -> URLRequest {
let grantingType: OAuthAuthorizationGranting.Type
switch type {
case .feedly:
@ -320,7 +339,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
fatalError("\(type) does not support OAuth authorization code granting.")
}
return grantingType.oauthAuthorizationCodeGrantRequest(for: client)
return grantingType.oauthAuthorizationCodeGrantRequest()
}
public static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse,
@ -337,17 +356,27 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
fatalError("\(accountType) does not support OAuth authorization code granting.")
}
grantingType.requestOAuthAccessToken(with: response, client: client, transport: transport, completionHandler: completionHandler)
grantingType.requestOAuthAccessToken(with: response, transport: transport, completionHandler: completionHandler)
}
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
self.delegate.refreshAll(for: self, completion: completion)
}
public func syncArticleStatus(completion: (() -> Void)? = nil) {
delegate.sendArticleStatus(for: self) { [unowned self] in
self.delegate.refreshArticleStatus(for: self) {
completion?()
public func syncArticleStatus(completion: ((Result<Void, Error>) -> Void)? = nil) {
delegate.sendArticleStatus(for: self) { [unowned self] result in
switch result {
case .success:
self.delegate.refreshArticleStatus(for: self) { result in
switch result {
case .success:
completion?(.success(()))
case .failure(let error):
completion?(.failure(error))
}
}
case .failure(let error):
completion?(.failure(error))
}
}
}
@ -372,19 +401,28 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
public func suspend() {
delegate.cancelAll(for: self)
save()
}
public func save() {
metadataFile.save()
feedMetadataFile.save()
webFeedMetadataFile.save()
opmlFile.save()
}
public func prepareForDeletion() {
delegate.accountWillBeDeleted(self)
}
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
var feedsToAdd = Set<Feed>()
var feedsToAdd = Set<WebFeed>()
items.forEach { (item) in
if let feedSpecifier = item.feedSpecifier {
let feed = newFeed(with: feedSpecifier)
let feed = newWebFeed(with: feedSpecifier)
feedsToAdd.insert(feed)
return
}
@ -407,22 +445,22 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
if let parentFolder = parentFolder {
for feed in feedsToAdd {
parentFolder.addFeed(feed)
parentFolder.addWebFeed(feed)
}
} else {
for feed in feedsToAdd {
addFeed(feed)
addWebFeed(feed)
}
}
}
public func resetFeedMetadataAndUnreadCounts() {
for feed in flattenedFeeds() {
feed.metadata = feedMetadata(feedURL: feed.url, feedID: feed.feedID)
public func resetWebFeedMetadataAndUnreadCounts() {
for feed in flattenedWebFeeds() {
feed.metadata = webFeedMetadata(feedURL: feed.url, webFeedID: feed.webFeedID)
}
fetchAllUnreadCounts()
NotificationCenter.default.post(name: .FeedMetadataDidChange, object: self, userInfo: nil)
NotificationCenter.default.post(name: .WebFeedMetadataDidChange, object: self, userInfo: nil)
}
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
@ -463,10 +501,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return folders?.first(where: { $0.nameForDisplay == displayName })
}
func newFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> Feed {
func newWebFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> WebFeed {
let feedURL = opmlFeedSpecifier.feedURL
let metadata = feedMetadata(feedURL: feedURL, feedID: feedURL)
let feed = Feed(account: self, url: opmlFeedSpecifier.feedURL, metadata: metadata)
let metadata = webFeedMetadata(feedURL: feedURL, webFeedID: feedURL)
let feed = WebFeed(account: self, url: opmlFeedSpecifier.feedURL, metadata: metadata)
if let feedTitle = opmlFeedSpecifier.title {
if feed.name == nil {
feed.name = feedTitle
@ -475,37 +513,37 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return feed
}
public func addFeed(_ feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.addFeed(for: self, with: feed, to: container, completion: completion)
public func addWebFeed(_ feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.addWebFeed(for: self, with: feed, to: container, completion: completion)
}
public func createFeed(url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
delegate.createFeed(for: self, url: url, name: name, container: container, completion: completion)
public func createWebFeed(url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
delegate.createWebFeed(for: self, url: url, name: name, container: container, completion: completion)
}
func createFeed(with name: String?, url: String, feedID: String, homePageURL: String?) -> Feed {
let metadata = feedMetadata(feedURL: url, feedID: feedID)
let feed = Feed(account: self, url: url, metadata: metadata)
func createWebFeed(with name: String?, url: String, webFeedID: String, homePageURL: String?) -> WebFeed {
let metadata = webFeedMetadata(feedURL: url, webFeedID: webFeedID)
let feed = WebFeed(account: self, url: url, metadata: metadata)
feed.name = name
feed.homePageURL = homePageURL
return feed
}
public func removeFeed(_ feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.removeFeed(for: self, with: feed, from: container, completion: completion)
public func removeWebFeed(_ feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.removeWebFeed(for: self, with: feed, from: container, completion: completion)
}
public func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.moveFeed(for: self, with: feed, from: from, to: to, completion: completion)
public func moveWebFeed(_ feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.moveWebFeed(for: self, with: feed, from: from, to: to, completion: completion)
}
public func renameFeed(_ feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.renameFeed(for: self, with: feed, to: name, completion: completion)
public func renameWebFeed(_ feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.renameWebFeed(for: self, with: feed, to: name, completion: completion)
}
public func restoreFeed(_ feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.restoreFeed(for: self, feed: feed, container: container, completion: completion)
public func restoreWebFeed(_ feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.restoreWebFeed(for: self, feed: feed, container: container, completion: completion)
}
public func addFolder(_ name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
@ -524,8 +562,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
delegate.restoreFolder(for: self, folder: folder, completion: completion)
}
func clearFeedMetadata(_ feed: Feed) {
feedMetadata[feed.url] = nil
func clearWebFeedMetadata(_ feed: WebFeed) {
webFeedMetadata[feed.url] = nil
}
func addFolder(_ folder: Folder) {
@ -534,15 +572,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
structureDidChange()
}
public func updateUnreadCounts(for feeds: Set<Feed>) {
if feeds.isEmpty {
public func updateUnreadCounts(for webFeeds: Set<WebFeed>) {
if webFeeds.isEmpty {
return
}
database.fetchUnreadCounts(for: feeds.feedIDs()) { (unreadCountDictionary) in
for feed in feeds {
if let unreadCount = unreadCountDictionary[feed.feedID] {
feed.unreadCount = unreadCount
database.fetchUnreadCounts(for: webFeeds.webFeedIDs()) { (unreadCountDictionary) in
for webFeed in webFeeds {
if let unreadCount = unreadCountDictionary[webFeed.webFeedID] {
webFeed.unreadCount = unreadCount
}
}
}
@ -558,8 +596,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return fetchTodayArticles()
case .unreadForFolder(let folder):
return fetchArticles(folder: folder)
case .feed(let feed):
return fetchArticles(feed: feed)
case .webFeed(let webFeed):
return fetchArticles(webFeed: webFeed)
case .articleIDs(let articleIDs):
return fetchArticles(articleIDs: articleIDs)
case .search(let searchString):
@ -579,8 +617,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
fetchTodayArticlesAsync(callback)
case .unreadForFolder(let folder):
fetchArticlesAsync(folder: folder, callback)
case .feed(let feed):
fetchArticlesAsync(feed: feed, callback)
case .webFeed(let webFeed):
fetchArticlesAsync(webFeed: webFeed, callback)
case .articleIDs(let articleIDs):
fetchArticlesAsync(articleIDs: articleIDs, callback)
case .search(let searchString):
@ -591,11 +629,11 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
public func fetchUnreadCountForToday(_ callback: @escaping (Int) -> Void) {
database.fetchUnreadCountForToday(for: flattenedFeeds().feedIDs(), callback: callback)
database.fetchUnreadCountForToday(for: flattenedWebFeeds().webFeedIDs(), callback: callback)
}
public func fetchUnreadCountForStarredArticles(_ callback: @escaping (Int) -> Void) {
database.fetchStarredAndUnreadCount(for: flattenedFeeds().feedIDs(), callback: callback)
database.fetchStarredAndUnreadCount(for: flattenedWebFeeds().webFeedIDs(), callback: callback)
}
public func fetchUnreadArticleIDs() -> Set<String> {
@ -610,12 +648,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return database.fetchArticleIDsForStatusesWithoutArticles()
}
public func unreadCount(for feed: Feed) -> Int {
return unreadCounts[feed.feedID] ?? 0
public func unreadCount(for webFeed: WebFeed) -> Int {
return unreadCounts[webFeed.webFeedID] ?? 0
}
public func setUnreadCount(_ unreadCount: Int, for feed: Feed) {
unreadCounts[feed.feedID] = unreadCount
public func setUnreadCount(_ unreadCount: Int, for webFeed: WebFeed) {
unreadCounts[webFeed.webFeedID] = unreadCount
}
public func structureDidChange() {
@ -624,36 +662,36 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
if !startingUp {
opmlFile.markAsDirty()
}
flattenedFeedsNeedUpdate = true
feedDictionaryNeedsUpdate = true
flattenedWebFeedsNeedUpdate = true
webFeedDictionaryNeedsUpdate = true
}
func update(_ feed: Feed, with parsedFeed: ParsedFeed, _ completion: @escaping (() -> Void)) {
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping (() -> Void)) {
// Used only by an On My Mac account.
feed.takeSettings(from: parsedFeed)
let feedIDsAndItems = [feed.feedID: parsedFeed.items]
update(feedIDsAndItems: feedIDsAndItems, defaultRead: false, completion: completion)
webFeed.takeSettings(from: parsedFeed)
let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items]
update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion)
}
func update(feedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping (() -> Void)) {
func update(webFeedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping (() -> Void)) {
assert(Thread.isMainThread)
guard !feedIDsAndItems.isEmpty else {
guard !webFeedIDsAndItems.isEmpty else {
completion()
return
}
database.update(feedIDsAndItems: feedIDsAndItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in
database.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: defaultRead) { (newArticles, updatedArticles) in
var userInfo = [String: Any]()
let feeds = Set(feedIDsAndItems.compactMap { (key, _) -> Feed? in
self.existingFeed(withFeedID: key)
let webFeeds = Set(webFeedIDsAndItems.compactMap { (key, _) -> WebFeed? in
self.existingWebFeed(withWebFeedID: key)
})
if let newArticles = newArticles, !newArticles.isEmpty {
self.updateUnreadCounts(for: feeds)
self.updateUnreadCounts(for: webFeeds)
userInfo[UserInfoKey.newArticles] = newArticles
}
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
userInfo[UserInfoKey.updatedArticles] = updatedArticles
}
userInfo[UserInfoKey.feeds] = feeds
userInfo[UserInfoKey.webFeeds] = webFeeds
completion()
@ -675,10 +713,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return updatedArticles
}
func ensureStatuses(_ articleIDs: Set<String>, _ defaultRead: Bool, _ statusKey: ArticleStatus.Key, _ flag: Bool) {
if !articleIDs.isEmpty {
database.ensureStatuses(articleIDs, defaultRead, statusKey, flag)
func ensureStatuses(_ articleIDs: Set<String>, _ defaultRead: Bool, _ statusKey: ArticleStatus.Key, _ flag: Bool, completionHandler: (() -> ())? = nil) {
guard !articleIDs.isEmpty else {
completionHandler?()
return
}
database.ensureStatuses(articleIDs, defaultRead, statusKey, flag, completionHandler: completionHandler)
}
/// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance.
@ -688,38 +728,38 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
// MARK: - Container
public func flattenedFeeds() -> Set<Feed> {
public func flattenedWebFeeds() -> Set<WebFeed> {
assert(Thread.isMainThread)
if flattenedFeedsNeedUpdate {
updateFlattenedFeeds()
if flattenedWebFeedsNeedUpdate {
updateFlattenedWebFeeds()
}
return _flattenedFeeds
return _flattenedWebFeeds
}
public func removeFeed(_ feed: Feed) {
topLevelFeeds.remove(feed)
public func removeWebFeed(_ webFeed: WebFeed) {
topLevelWebFeeds.remove(webFeed)
structureDidChange()
postChildrenDidChangeNotification()
}
public func removeFeeds(_ feeds: Set<Feed>) {
guard !feeds.isEmpty else {
public func removeFeeds(_ webFeeds: Set<WebFeed>) {
guard !webFeeds.isEmpty else {
return
}
topLevelFeeds.subtract(feeds)
topLevelWebFeeds.subtract(webFeeds)
structureDidChange()
postChildrenDidChangeNotification()
}
public func addFeed(_ feed: Feed) {
topLevelFeeds.insert(feed)
public func addWebFeed(_ webFeed: WebFeed) {
topLevelWebFeeds.insert(webFeed)
structureDidChange()
postChildrenDidChangeNotification()
}
func addFeedIfNotInAnyFolder(_ feed: Feed) {
if !flattenedFeeds().contains(feed) {
addFeed(feed)
func addFeedIfNotInAnyFolder(_ webFeed: WebFeed) {
if !flattenedWebFeeds().contains(webFeed) {
addWebFeed(webFeed)
}
}
@ -733,7 +773,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
public func debugDropConditionalGetInfo() {
#if DEBUG
flattenedFeeds().forEach{ $0.debugDropConditionalGetInfo() }
flattenedWebFeeds().forEach{ $0.debugDropConditionalGetInfo() }
#endif
}
@ -759,14 +799,14 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
@objc func unreadCountDidChange(_ note: Notification) {
if let feed = note.object as? Feed, feed.account === self {
if let feed = note.object as? WebFeed, feed.account === self {
updateUnreadCount()
}
}
@objc func batchUpdateDidPerform(_ note: Notification) {
flattenedFeedsNeedUpdate = true
rebuildFeedDictionaries()
flattenedWebFeedsNeedUpdate = true
rebuildWebFeedDictionaries()
updateUnreadCount()
}
@ -812,11 +852,11 @@ extension Account: AccountMetadataDelegate {
// MARK: - FeedMetadataDelegate
extension Account: FeedMetadataDelegate {
extension Account: WebFeedMetadataDelegate {
func valueDidChange(_ feedMetadata: FeedMetadata, key: FeedMetadata.CodingKeys) {
feedMetadataFile.markAsDirty()
guard let feed = existingFeed(withFeedID: feedMetadata.feedID) else {
func valueDidChange(_ feedMetadata: WebFeedMetadata, key: WebFeedMetadata.CodingKeys) {
webFeedMetadataFile.markAsDirty()
guard let feed = existingWebFeed(withWebFeedID: feedMetadata.webFeedID) else {
return
}
feed.postFeedSettingDidChangeNotification(key)
@ -828,11 +868,11 @@ extension Account: FeedMetadataDelegate {
private extension Account {
func fetchStarredArticles() -> Set<Article> {
return database.fetchStarredArticles(flattenedFeeds().feedIDs())
return database.fetchStarredArticles(flattenedWebFeeds().webFeedIDs())
}
func fetchStarredArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchedStarredArticlesAsync(flattenedFeeds().feedIDs(), callback)
database.fetchedStarredArticlesAsync(flattenedWebFeeds().webFeedIDs(), callback)
}
func fetchUnreadArticles() -> Set<Article> {
@ -844,11 +884,11 @@ private extension Account {
}
func fetchTodayArticles() -> Set<Article> {
return database.fetchTodayArticles(flattenedFeeds().feedIDs())
return database.fetchTodayArticles(flattenedWebFeeds().webFeedIDs())
}
func fetchTodayArticlesAsync(_ callback: @escaping ArticleSetBlock) {
database.fetchTodayArticlesAsync(flattenedFeeds().feedIDs(), callback)
database.fetchTodayArticlesAsync(flattenedWebFeeds().webFeedIDs(), callback)
}
func fetchArticles(folder: Folder) -> Set<Article> {
@ -859,21 +899,21 @@ private extension Account {
fetchUnreadArticlesAsync(forContainer: folder, callback)
}
func fetchArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchArticles(feed.feedID)
validateUnreadCount(feed, articles)
func fetchArticles(webFeed: WebFeed) -> Set<Article> {
let articles = database.fetchArticles(webFeed.webFeedID)
validateUnreadCount(webFeed, articles)
return articles
}
func fetchArticlesAsync(feed: Feed, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesAsync(feed.feedID) { [weak self] (articles) in
self?.validateUnreadCount(feed, articles)
func fetchArticlesAsync(webFeed: WebFeed, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesAsync(webFeed.webFeedID) { [weak self] (articles) in
self?.validateUnreadCount(webFeed, articles)
callback(articles)
}
}
func fetchArticlesMatching(_ searchString: String) -> Set<Article> {
return database.fetchArticlesMatching(searchString, flattenedFeeds().feedIDs())
return database.fetchArticlesMatching(searchString, flattenedWebFeeds().webFeedIDs())
}
func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set<String>) -> Set<Article> {
@ -881,7 +921,7 @@ private extension Account {
}
func fetchArticlesMatchingAsync(_ searchString: String, _ callback: @escaping ArticleSetBlock) {
database.fetchArticlesMatchingAsync(searchString, flattenedFeeds().feedIDs(), callback)
database.fetchArticlesMatchingAsync(searchString, flattenedWebFeeds().webFeedIDs(), callback)
}
func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set<String>, _ callback: @escaping ArticleSetBlock) {
@ -896,13 +936,13 @@ private extension Account {
return database.fetchArticlesAsync(articleIDs: articleIDs, callback)
}
func fetchUnreadArticles(feed: Feed) -> Set<Article> {
let articles = database.fetchUnreadArticles(Set([feed.feedID]))
validateUnreadCount(feed, articles)
func fetchUnreadArticles(webFeed: WebFeed) -> Set<Article> {
let articles = database.fetchUnreadArticles(Set([webFeed.webFeedID]))
validateUnreadCount(webFeed, articles)
return articles
}
func fetchUnreadArticlesAsync(for feed: Feed, callback: @escaping (Set<Article>) -> Void) {
func fetchUnreadArticlesAsync(for webFeed: WebFeed, callback: @escaping (Set<Article>) -> Void) {
// database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in
// self?.validateUnreadCount(feed, articles)
// callback(articles)
@ -911,48 +951,48 @@ private extension Account {
func fetchUnreadArticles(forContainer container: Container) -> Set<Article> {
let feeds = container.flattenedFeeds()
let articles = database.fetchUnreadArticles(feeds.feedIDs())
let feeds = container.flattenedWebFeeds()
let articles = database.fetchUnreadArticles(feeds.webFeedIDs())
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
return articles
}
func fetchUnreadArticlesAsync(forContainer container: Container, _ callback: @escaping ArticleSetBlock) {
let feeds = container.flattenedFeeds()
database.fetchUnreadArticlesAsync(feeds.feedIDs()) { [weak self] (articles) in
self?.validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
let webFeeds = container.flattenedWebFeeds()
database.fetchUnreadArticlesAsync(webFeeds.webFeedIDs()) { [weak self] (articles) in
self?.validateUnreadCountsAfterFetchingUnreadArticles(webFeeds, articles)
callback(articles)
}
}
func validateUnreadCountsAfterFetchingUnreadArticles(_ feeds: Set<Feed>, _ articles: Set<Article>) {
func validateUnreadCountsAfterFetchingUnreadArticles(_ webFeeds: Set<WebFeed>, _ articles: Set<Article>) {
// Validate unread counts. This was the site of a performance slowdown:
// it was calling going through the entire list of articles once per feed:
// feeds.forEach { validateUnreadCount($0, articles) }
// Now we loop through articles exactly once. This makes a huge difference.
var unreadCountStorage = [String: Int]() // [FeedID: Int]
var unreadCountStorage = [String: Int]() // [WebFeedID: Int]
for article in articles where !article.status.read {
unreadCountStorage[article.feedID, default: 0] += 1
unreadCountStorage[article.webFeedID, default: 0] += 1
}
feeds.forEach { (feed) in
let unreadCount = unreadCountStorage[feed.feedID, default: 0]
feed.unreadCount = unreadCount
webFeeds.forEach { (webFeed) in
let unreadCount = unreadCountStorage[webFeed.webFeedID, default: 0]
webFeed.unreadCount = unreadCount
}
}
func validateUnreadCount(_ feed: Feed, _ articles: Set<Article>) {
func validateUnreadCount(_ webFeed: WebFeed, _ articles: Set<Article>) {
// articles must contain all the unread articles for the feed.
// The unread number should match the feeds unread count.
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
if article.feed == feed && !article.status.read {
if article.webFeed == webFeed && !article.status.read {
return result + 1
}
return result
}
feed.unreadCount = feedUnreadCount
webFeed.unreadCount = feedUnreadCount
}
}
@ -960,37 +1000,37 @@ private extension Account {
private extension Account {
func feedMetadata(feedURL: String, feedID: String) -> FeedMetadata {
if let d = feedMetadata[feedURL] {
func webFeedMetadata(feedURL: String, webFeedID: String) -> WebFeedMetadata {
if let d = webFeedMetadata[feedURL] {
assert(d.delegate === self)
return d
}
let d = FeedMetadata(feedID: feedID)
let d = WebFeedMetadata(webFeedID: webFeedID)
d.delegate = self
feedMetadata[feedURL] = d
webFeedMetadata[feedURL] = d
return d
}
func updateFlattenedFeeds() {
var feeds = Set<Feed>()
feeds.formUnion(topLevelFeeds)
func updateFlattenedWebFeeds() {
var feeds = Set<WebFeed>()
feeds.formUnion(topLevelWebFeeds)
for folder in folders! {
feeds.formUnion(folder.flattenedFeeds())
feeds.formUnion(folder.flattenedWebFeeds())
}
_flattenedFeeds = feeds
flattenedFeedsNeedUpdate = false
_flattenedWebFeeds = feeds
flattenedWebFeedsNeedUpdate = false
}
func rebuildFeedDictionaries() {
var idDictionary = [String: Feed]()
func rebuildWebFeedDictionaries() {
var idDictionary = [String: WebFeed]()
flattenedFeeds().forEach { (feed) in
idDictionary[feed.feedID] = feed
flattenedWebFeeds().forEach { (feed) in
idDictionary[feed.webFeedID] = feed
}
_idToFeedDictionary = idDictionary
feedDictionaryNeedsUpdate = false
_idToWebFeedDictionary = idDictionary
webFeedDictionaryNeedsUpdate = false
}
func updateUnreadCount() {
@ -998,21 +1038,21 @@ private extension Account {
return
}
var updatedUnreadCount = 0
for feed in flattenedFeeds() {
for feed in flattenedWebFeeds() {
updatedUnreadCount += feed.unreadCount
}
unreadCount = updatedUnreadCount
}
func noteStatusesForArticlesDidChange(_ articles: Set<Article>) {
let feeds = Set(articles.compactMap { $0.feed })
let feeds = Set(articles.compactMap { $0.webFeed })
let statuses = Set(articles.map { $0.status })
// .UnreadCountDidChange notification will get sent to Folder and Account objects,
// which will update their own unread counts.
updateUnreadCounts(for: feeds)
NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.statuses: statuses, UserInfoKey.articles: articles, UserInfoKey.feeds: feeds])
NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.statuses: statuses, UserInfoKey.articles: articles, UserInfoKey.webFeeds: feeds])
}
func fetchAllUnreadCounts() {
@ -1026,10 +1066,10 @@ private extension Account {
return
}
self.flattenedFeeds().forEach{ (feed) in
self.flattenedWebFeeds().forEach{ (feed) in
// When the unread count is zero, it wont appear in unreadCountDictionary.
if let unreadCount = unreadCountDictionary[feed.feedID] {
if let unreadCount = unreadCountDictionary[feed.webFeedID] {
feed.unreadCount = unreadCount
}
else {
@ -1047,8 +1087,8 @@ private extension Account {
extension Account {
public func existingFeed(withFeedID feedID: String) -> Feed? {
return idToFeedDictionary[feedID]
public func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? {
return idToWebFeedDictionary[webFeedID]
}
}
@ -1058,7 +1098,7 @@ extension Account: OPMLRepresentable {
public func OPMLString(indentLevel: Int, strictConformance: Bool) -> String {
var s = ""
for feed in topLevelFeeds {
for feed in topLevelWebFeeds {
s += feed.OPMLString(indentLevel: indentLevel + 1, strictConformance: strictConformance)
}
for folder in folders! {

View File

@ -20,12 +20,11 @@
5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; };
5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; };
510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; };
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* FeedMetadataFile.swift */; };
510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; };
511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; };
513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; };
5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; };
5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; };
5133230E2281089500C30F19 /* icons.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230D2281089500C30F19 /* icons.json */; };
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5133230F22810E5700C30F19 /* FeedbinIcon.swift */; };
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; };
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; };
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; };
@ -42,6 +41,7 @@
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; };
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; };
51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; };
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; };
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; };
51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; };
51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; };
@ -52,7 +52,6 @@
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; };
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; };
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; };
51FE1008234635A20056195D /* DeepLinkProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FE1007234635A20056195D /* DeepLinkProvider.swift */; };
552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; };
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */; };
552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F0229D5D5A009559E0 /* ReaderAPITag.swift */; };
@ -67,7 +66,7 @@
841D4D702106B40400DD04E6 /* ArticlesDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D6F2106B40400DD04E6 /* ArticlesDatabase.framework */; };
841D4D722106B40A00DD04E6 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D712106B40A00DD04E6 /* Articles.framework */; };
84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84245C841FDDD8CB0074AFBB /* FeedbinSubscription.swift */; };
844B297D2106C7EC004020B3 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844B297C2106C7EC004020B3 /* Feed.swift */; };
844B297D2106C7EC004020B3 /* WebFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844B297C2106C7EC004020B3 /* WebFeed.swift */; };
844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844B297E210CE37E004020B3 /* UnreadCountProvider.swift */; };
844B2981210CE3BF004020B3 /* RSWeb.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 844B2980210CE3BF004020B3 /* RSWeb.framework */; };
8469F81C1F6DD15E0084783E /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848935101F62486800CEBD24 /* Account.swift */; };
@ -76,7 +75,7 @@
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8419742D1F6DDE96006346C4 /* LocalAccountRefresher.swift */; };
846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846E77531F6F00E300A165E2 /* AccountManager.swift */; };
848935001F62484F00CEBD24 /* Account.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848934F61F62484F00CEBD24 /* Account.framework */; };
84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B2D4CE2238C13D00498ADA /* FeedMetadata.swift */; };
84B2D4D02238CD8A00498ADA /* WebFeedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B2D4CE2238C13D00498ADA /* WebFeedMetadata.swift */; };
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */; };
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */; };
84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */; };
@ -85,6 +84,11 @@
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 */; };
9E0260CB236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0260CA236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.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 */; };
@ -92,29 +96,44 @@
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 */; };
9E672394236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E672393236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift */; };
9E672396236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E672395236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift */; };
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 */; };
9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */; };
9E784EC0237E8BE100099B1B /* FeedlyLogoutOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E784EBF237E8BE100099B1B /* FeedlyLogoutOperationTests.swift */; };
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 */; };
9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E964E9E23754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift */; };
9E964EBA23754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E964EB923754B4000A7AF2E /* OAuthAccountAuthorizationOperation.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 */; };
@ -122,14 +141,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 */
@ -192,12 +230,11 @@
5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = "<group>"; };
5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = "<group>"; };
510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = "<group>"; };
510BD112232C3E9D002692E4 /* FeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMetadataFile.swift; sourceTree = "<group>"; };
510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = "<group>"; };
511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = "<group>"; };
513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = "<group>"; };
513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = "<group>"; };
5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = "<group>"; };
5133230D2281089500C30F19 /* icons.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = icons.json; sourceTree = "<group>"; };
5133230F22810E5700C30F19 /* FeedbinIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinIcon.swift; sourceTree = "<group>"; };
5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = "<group>"; };
5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = "<group>"; };
5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = "<group>"; };
@ -215,6 +252,7 @@
5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = "<group>"; };
518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = "<group>"; };
51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = "<group>"; };
51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = "<group>"; };
@ -225,7 +263,6 @@
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
51FE1007234635A20056195D /* DeepLinkProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkProvider.swift; sourceTree = "<group>"; };
552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = "<group>"; };
552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPISubscription.swift; sourceTree = "<group>"; };
552032F0229D5D5A009559E0 /* ReaderAPITag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITag.swift; sourceTree = "<group>"; };
@ -243,7 +280,7 @@
841D4D6F2106B40400DD04E6 /* ArticlesDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ArticlesDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
841D4D712106B40A00DD04E6 /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84245C841FDDD8CB0074AFBB /* FeedbinSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinSubscription.swift; sourceTree = "<group>"; };
844B297C2106C7EC004020B3 /* Feed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
844B297C2106C7EC004020B3 /* WebFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebFeed.swift; sourceTree = "<group>"; };
844B297E210CE37E004020B3 /* UnreadCountProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnreadCountProvider.swift; sourceTree = "<group>"; };
844B2980210CE3BF004020B3 /* RSWeb.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSWeb.framework; sourceTree = BUILT_PRODUCTS_DIR; };
846E77531F6F00E300A165E2 /* AccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = "<group>"; };
@ -253,7 +290,7 @@
848935061F62485000CEBD24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
848935101F62486800CEBD24 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; };
84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadata.swift; sourceTree = "<group>"; };
84B2D4CE2238C13D00498ADA /* FeedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMetadata.swift; sourceTree = "<group>"; };
84B2D4CE2238C13D00498ADA /* WebFeedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadata.swift; sourceTree = "<group>"; };
84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerPath.swift; sourceTree = "<group>"; };
84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedRefreshProgress.swift; sourceTree = "<group>"; };
84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; };
@ -261,6 +298,11 @@
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>"; };
9E0260CA236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshAccessTokenOperationTests.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>"; };
@ -268,29 +310,44 @@
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>"; };
9E672393236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshAccessTokenOperation.swift; sourceTree = "<group>"; };
9E672395236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAcessTokenRefreshing.swift; 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>"; };
9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyLogoutOperation.swift; sourceTree = "<group>"; };
9E784EBF237E8BE100099B1B /* FeedlyLogoutOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyLogoutOperationTests.swift; 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>"; };
9E964E9E23754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OAuthAuthorizationClient+Feedly.swift"; sourceTree = "<group>"; };
9E964EB923754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAccountAuthorizationOperation.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>"; };
@ -298,14 +355,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>"; };
@ -389,7 +465,10 @@
51D58756227F62E300900287 /* JSON */ = {
isa = PBXGroup;
children = (
5133230D2281089500C30F19 /* icons.json */,
9EC804F0236C21320057CFCB /* feedly_unreads_1000.json */,
9EC804F1236C21320057CFCB /* feedly_unreads_initial.json */,
9EC804EE236C20DD0057CFCB /* feedly_macintosh_initial.json */,
9EC804EC236C206A0057CFCB /* feedly_collections_initial.json */,
5133230B2281088A00C30F19 /* subscriptions_add.json */,
513323092281082F00C30F19 /* subscriptions_initial.json */,
5165D71422821C2400D9D53D /* taggings_add.json */,
@ -452,7 +531,6 @@
5144EA48227B497600D19003 /* FeedbinAPICaller.swift */,
51E490352288C37100C791F0 /* FeedbinDate.swift */,
84CAD7151FDF2E22000F0755 /* FeedbinEntry.swift */,
5133230F22810E5700C30F19 /* FeedbinIcon.swift */,
5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */,
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */,
84245C841FDDD8CB0074AFBB /* FeedbinSubscription.swift */,
@ -493,12 +571,13 @@
8419740D1F6DD25F006346C4 /* Container.swift */,
84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */,
84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */,
844B297C2106C7EC004020B3 /* Feed.swift */,
84B2D4CE2238C13D00498ADA /* FeedMetadata.swift */,
510BD112232C3E9D002692E4 /* FeedMetadataFile.swift */,
51BC8FCB237EC055004F8B56 /* Feed.swift */,
511B9803237CD4270028BCAA /* FeedIdentifier.swift */,
841974001F6DD1EC006346C4 /* Folder.swift */,
844B297E210CE37E004020B3 /* UnreadCountProvider.swift */,
51FE1007234635A20056195D /* DeepLinkProvider.swift */,
844B297C2106C7EC004020B3 /* WebFeed.swift */,
84B2D4CE2238C13D00498ADA /* WebFeedMetadata.swift */,
510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */,
5165D71F22835E9800D9D53D /* FeedFinder */,
515E4EB12324FF7D0057B0E7 /* Credentials */,
8419742B1F6DDE84006346C4 /* LocalAccount */,
@ -541,47 +620,42 @@
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 */,
9E0260CA236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.swift */,
9E784EBF237E8BE100099B1B /* FeedlyLogoutOperationTests.swift */,
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 = (
@ -589,36 +663,47 @@
9EE4CCF9234F106600FBAE4B /* FeedlyFeedContainerValidator.swift */,
9ECC9A84234DC16E009B5144 /* FeedlyAccountDelegateError.swift */,
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */,
9E964E9E23754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift */,
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
9E964EB923754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift */,
9E672395236F7E68000BE141 /* OAuthAcessTokenRefreshing.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 */,
9E672393236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift */,
9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */,
);
path = Refresh;
path = Operations;
sourceTree = "<group>";
};
9EBC31B32338AC2E002A567B /* Models */ = {
@ -630,6 +715,7 @@
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */,
9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */,
9EAEC625233318400085D7C9 /* FeedlyStream.swift */,
9EF1B10823590E93000A486A /* FeedlyStreamIds.swift */,
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */,
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */,
9E1773D22345700E0056A5A8 /* FeedlyLink.swift */,
@ -638,6 +724,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 = (
@ -668,9 +766,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 8489350A1F62485000CEBD24 /* Build configuration list for PBXNativeTarget "Account" */;
buildPhases = (
<<<<<<< HEAD
3BF610B123571C31000EF978 /* Run Script: Update FeedWranglerConfig.swift */,
848934F11F62484F00CEBD24 /* Sources */,
3BF610B223571C32000EF978 /* Run Script: Reset FeedWranglerConfig.swift */,
=======
9E964EBB2375512300A7AF2E /* Run Script: Update OAuthAuthorizationClient+Feedly.swift */,
848934F11F62484F00CEBD24 /* Sources */,
9E964EBC2375517100A7AF2E /* Run Script: Reset OAuthAuthorizationClient+Feedly.swift */,
>>>>>>> master
848934F21F62484F00CEBD24 /* Frameworks */,
848934F31F62484F00CEBD24 /* Headers */,
848934F41F62484F00CEBD24 /* Resources */,
@ -716,11 +820,11 @@
848934F51F62484F00CEBD24 = {
CreatedOnToolsVersion = 9.0;
LastSwiftMigration = 0900;
ProvisioningStyle = Manual;
ProvisioningStyle = Automatic;
};
848934FE1F62484F00CEBD24 = {
CreatedOnToolsVersion = 9.0;
ProvisioningStyle = Manual;
ProvisioningStyle = Automatic;
};
};
};
@ -802,25 +906,22 @@
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;
@ -882,6 +983,42 @@
shellPath = /bin/sh;
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 ../../buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
};
9E964EBB2375512300A7AF2E /* Run Script: Update OAuthAuthorizationClient+Feedly.swift */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Update OAuthAuthorizationClient+Feedly.swift";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "FAILED=false\n\nif [ -z \"${FEEDLY_CLIENT_ID}\" ]; then\necho \"Missing Feedly Client ID\"\nFAILED=true\nfi\n\nif [ -z \"${FEEDLY_CLIENT_SECRET}\" ]; then\necho \"Missing Feedly Client Secret\"\nFAILED=true\nfi\n\nFEEDLY_CLIENT_SOURCE=\"${SRCROOT}/Feedly/OAuthAuthorizationClient+Feedly.swift\"\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feedly client ID or secret. ${FEEDLY_CLIENT_SOURCE} not changed.\"\nexit 0\nfi\n\n# echo \"Substituting variables in: ${FEEDLY_CLIENT_SOURCE}\"\n\nif [ -e \"${FEEDLY_CLIENT_SOURCE}\" ]\nthen\n sed -i .tmp \"s|{FEEDLY_CLIENT_ID}|${FEEDLY_CLIENT_ID}|g; s|{FEEDLY_CLIENT_SECRET}|${FEEDLY_CLIENT_SECRET}|g\" $FEEDLY_CLIENT_SOURCE\n # echo \"`git diff ${FEEDLY_CLIENT_SOURCE}`\"\n rm -f \"${FEEDLY_CLIENT_SOURCE}.tmp\"\nelse\n echo \"File does not exist at ${FEEDLY_CLIENT_SOURCE}. Has it been moved or renamed?\"\n exit -1\nfi\n\necho \"All env values found!\"\n";
};
9E964EBC2375517100A7AF2E /* Run Script: Reset OAuthAuthorizationClient+Feedly.swift */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script: Reset OAuthAuthorizationClient+Feedly.swift";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "git checkout \"${SRCROOT}/Feedly/OAuthAuthorizationClient+Feedly.swift\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -894,7 +1031,9 @@
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 */,
9E672394236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift in Sources */,
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */,
@ -904,37 +1043,58 @@
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
3BF6112423572A62000EF978 /* FeedWranglerSubscription.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 */,
511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
<<<<<<< HEAD
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */,
3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */,
=======
9E713653233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift in Sources */,
>>>>>>> master
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */,
510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */,
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */,
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */,
846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */,
515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */,
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */,
9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */,
5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */,
<<<<<<< HEAD
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */,
844B297D2106C7EC004020B3 /* Feed.swift in Sources */,
3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */,
=======
9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */,
9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */,
9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */,
9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */,
844B297D2106C7EC004020B3 /* WebFeed.swift in Sources */,
9E964EBA23754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift in Sources */,
>>>>>>> master
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */,
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */,
9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */,
515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */,
9E672396236F7E68000BE141 /* OAuthAcessTokenRefreshing.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 */,
84B2D4D02238CD8A00498ADA /* WebFeedMetadata.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 */,
9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */,
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */,
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */,
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */,
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */,
@ -957,10 +1117,11 @@
9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */,
9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */,
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
51FE1008234635A20056195D /* DeepLinkProvider.swift in Sources */,
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */,
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */,
9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */,
9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */,
@ -976,6 +1137,7 @@
3BF610C823571CD4000EF978 /* FeedWranglerConfig.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;
@ -984,14 +1146,39 @@
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 */,
9E784EC0237E8BE100099B1B /* FeedlyLogoutOperationTests.swift in Sources */,
9EC228552362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift in Sources */,
9E03C120235E62A500FB6D9E /* FeedlyMirrorCollectionsAsFoldersOperationTests.swift in Sources */,
9E489E912360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift in Sources */,
9E0260CB236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.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

@ -22,9 +22,10 @@ protocol AccountDelegate {
var refreshProgress: DownloadProgress { get }
func cancelAll(for account: Account)
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void)
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void))
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void))
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void)
@ -32,19 +33,21 @@ protocol AccountDelegate {
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void)
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void)
func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void)
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void)
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void)
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>?
// Called at the end of accounts init method.
func accountDidInitialize(_ account: Account)
func accountWillBeDeleted(_ account: Account)
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void)

View File

@ -138,6 +138,8 @@ public final class AccountManager: UnreadCountProvider {
return
}
account.prepareForDeletion()
accountsDictionary.removeValue(forKey: account.accountID)
account.isDeleted = true
@ -160,6 +162,10 @@ public final class AccountManager: UnreadCountProvider {
return accountsDictionary[accountID]
}
public func suspendAll() {
accounts.forEach { $0.suspend() }
}
public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() ->Void)? = nil) {
let group = DispatchGroup()
@ -187,7 +193,7 @@ public final class AccountManager: UnreadCountProvider {
activeAccounts.forEach {
group.enter()
$0.syncArticleStatus() {
$0.syncArticleStatus() { _ in
group.leave()
}
}
@ -203,7 +209,7 @@ public final class AccountManager: UnreadCountProvider {
public func anyAccountHasAtLeastOneFeed() -> Bool {
for account in activeAccounts {
if account.hasAtLeastOneFeed() {
if account.hasAtLeastOneWebFeed() {
return true
}
}
@ -213,7 +219,7 @@ public final class AccountManager: UnreadCountProvider {
public func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
for account in activeAccounts {
if let _ = account.existingFeed(withURL: urlString) {
if let _ = account.existingWebFeed(withURL: urlString) {
return true
}
}

View File

@ -23,7 +23,6 @@ class AccountFeedbinFolderContentsSyncTest: XCTestCase {
testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "tags_add.json"
testTransport.testFiles["https://api.feedbin.com/v2/subscriptions.json"] = "subscriptions_initial.json"
testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "taggings_initial.json"
testTransport.testFiles["https://api.feedbin.com/v2/icons.json"] = "icons.json"
let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
// Test initial folders
@ -34,8 +33,8 @@ class AccountFeedbinFolderContentsSyncTest: XCTestCase {
waitForExpectations(timeout: 5, handler: nil)
let folder = account.folders?.filter { $0.name == "Developers" } .first!
XCTAssertEqual(156, folder?.topLevelFeeds.count ?? 0)
XCTAssertEqual(2, account.topLevelFeeds.count)
XCTAssertEqual(156, folder?.topLevelWebFeeds.count ?? 0)
XCTAssertEqual(2, account.topLevelWebFeeds.count)
// Test Adding a Feed to the folder
testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "taggings_add.json"
@ -46,8 +45,8 @@ class AccountFeedbinFolderContentsSyncTest: XCTestCase {
}
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(157, folder?.topLevelFeeds.count ?? 0)
XCTAssertEqual(1, account.topLevelFeeds.count)
XCTAssertEqual(157, folder?.topLevelWebFeeds.count ?? 0)
XCTAssertEqual(1, account.topLevelWebFeeds.count)
// Test Deleting some Feeds from the folder
testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "taggings_delete.json"
@ -58,8 +57,8 @@ class AccountFeedbinFolderContentsSyncTest: XCTestCase {
}
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(153, folder?.topLevelFeeds.count ?? 0)
XCTAssertEqual(5, account.topLevelFeeds.count)
XCTAssertEqual(153, folder?.topLevelWebFeeds.count ?? 0)
XCTAssertEqual(5, account.topLevelWebFeeds.count)
TestAccountManager.shared.deleteAccount(account)

View File

@ -22,7 +22,6 @@ class AccountFeedbinSyncTest: XCTestCase {
let testTransport = TestTransport()
testTransport.testFiles["tags.json"] = "tags_add.json"
testTransport.testFiles["subscriptions.json"] = "subscriptions_initial.json"
testTransport.testFiles["icons.json"] = "icons.json"
let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
// Test initial folders
@ -37,13 +36,12 @@ class AccountFeedbinSyncTest: XCTestCase {
}
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(224, account.flattenedFeeds().count)
XCTAssertEqual(224, account.flattenedWebFeeds().count)
let daringFireball = account.idToFeedDictionary["1296379"]
let daringFireball = account.idToWebFeedDictionary["1296379"]
XCTAssertEqual("Daring Fireball", daringFireball!.name)
XCTAssertEqual("https://daringfireball.net/feeds/json", daringFireball!.url)
XCTAssertEqual("https://daringfireball.net/", daringFireball!.homePageURL)
XCTAssertEqual("https://favicons.feedbinusercontent.com/6ac/6acc098f35ed2bcc0915ca89d50a97e5793eda45.png", daringFireball!.faviconURL)
// Test Adding a Feed
testTransport.testFiles["subscriptions.json"] = "subscriptions_add.json"
@ -59,13 +57,12 @@ class AccountFeedbinSyncTest: XCTestCase {
}
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(225, account.flattenedFeeds().count)
XCTAssertEqual(225, account.flattenedWebFeeds().count)
let bPixels = account.idToFeedDictionary["1096623"]
let bPixels = account.idToWebFeedDictionary["1096623"]
XCTAssertEqual("Beautiful Pixels", bPixels?.name)
XCTAssertEqual("https://feedpress.me/beautifulpixels", bPixels?.url)
XCTAssertEqual("https://beautifulpixels.com/", bPixels?.homePageURL)
XCTAssertEqual("https://favicons.feedbinusercontent.com/ea0/ea010c658d6e356e49ab239b793dc415af707b05.png", bPixels?.faviconURL)
TestAccountManager.shared.deleteAccount(account)

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.flattenedWebFeeds().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.flattenedWebFeeds()
let ingestedIds = Set(accountFeeds.map { $0.webFeedID })
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.topLevelWebFeeds.map { $0.webFeedID }.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.flattenedWebFeeds().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.flattenedWebFeeds()
let ingestedIds = Set(accountFeeds.map { $0.webFeedID })
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.topLevelWebFeeds.map { $0.webFeedID }.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,216 @@
//
// FeedlyLogoutOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 15/11/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
class FeedlyLogoutOperationTests: 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()
}
private func getTokens(for account: Account) throws -> (accessToken: Credentials, refreshToken: Credentials) {
guard let accessToken = try account.retrieveCredentials(type: .oauthAccessToken), let refreshToken = try account.retrieveCredentials(type: .oauthRefreshToken) else {
XCTFail("Unable to retrieve access and/or refresh token from account.")
throw CredentialsError.incompleteCredentials
}
return (accessToken, refreshToken)
}
class TestFeedlyLogoutService: FeedlyLogoutService {
var mockResult: Result<Void, Error>?
var logoutExpectation: XCTestExpectation?
func logout(completionHandler: @escaping (Result<Void, 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.logoutExpectation?.fulfill()
}
}
}
func testCancel() {
let service = TestFeedlyLogoutService()
service.logoutExpectation = expectation(description: "Did Call Logout")
service.logoutExpectation?.isInverted = true
let accessToken: Credentials
let refreshToken: Credentials
do {
(accessToken, refreshToken) = try getTokens(for: account)
} catch {
XCTFail("Could not retrieve credentials to verify their integrity later.")
return
}
let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
logout.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(logout)
logout.cancel()
waitForExpectations(timeout: 1)
XCTAssertTrue(logout.isCancelled)
XCTAssertTrue(logout.isFinished)
do {
let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
let accountRefreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
XCTAssertEqual(accountAccessToken, accessToken)
XCTAssertEqual(accountRefreshToken, refreshToken)
} catch {
XCTFail("Could not verify tokens were left intact. Did the operation delete them?")
}
}
func testLogoutSuccess() {
let service = TestFeedlyLogoutService()
service.logoutExpectation = expectation(description: "Did Call Logout")
service.mockResult = .success(())
let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
logout.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(logout)
waitForExpectations(timeout: 1)
XCTAssertFalse(logout.isCancelled)
do {
let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
let accountRefreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
XCTAssertNil(accountAccessToken)
XCTAssertNil(accountRefreshToken)
} catch {
XCTFail("Could not verify tokens were deleted.")
}
}
class TestLogoutDelegate: FeedlyOperationDelegate {
var error: Error?
var didFailExpectation: XCTestExpectation?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
self.error = error
didFailExpectation?.fulfill()
}
}
func testLogoutMissingAccessToken() {
support.removeCredentials(matching: .oauthAccessToken, from: account)
let (_, service) = support.makeMockNetworkStack()
service.credentials = nil
let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
let delegate = TestLogoutDelegate()
delegate.didFailExpectation = expectation(description: "Did Fail")
logout.delegate = delegate
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
logout.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(logout)
waitForExpectations(timeout: 1)
XCTAssertFalse(logout.isCancelled)
do {
let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
XCTAssertNil(accountAccessToken)
} catch {
XCTFail("Could not verify tokens were deleted.")
}
XCTAssertNotNil(delegate.error, "Should have failed with error.")
if let error = delegate.error {
switch error {
case CredentialsError.incompleteCredentials:
break
default:
XCTFail("Expected \(CredentialsError.incompleteCredentials)")
}
}
}
func testLogoutFailure() {
let service = TestFeedlyLogoutService()
service.logoutExpectation = expectation(description: "Did Call Logout")
service.mockResult = .failure(URLError(.timedOut))
let accessToken: Credentials
let refreshToken: Credentials
do {
(accessToken, refreshToken) = try getTokens(for: account)
} catch {
XCTFail("Could not retrieve credentials to verify their integrity later.")
return
}
let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
logout.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(logout)
waitForExpectations(timeout: 1)
XCTAssertFalse(logout.isCancelled)
do {
let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
let accountRefreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
XCTAssertEqual(accountAccessToken, accessToken)
XCTAssertEqual(accountRefreshToken, refreshToken)
} catch {
XCTFail("Could not verify tokens were left intact. Did the operation delete them?")
}
}
}

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.flattenedWebFeeds().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.flattenedWebFeeds()
XCTAssertTrue(feeds.isEmpty)
}
}

View File

@ -0,0 +1,147 @@
//
// 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")
testOperation.didCallMainExpectation?.isInverted = true
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,217 @@
//
// FeedlyRefreshAccessTokenOperationTests.swift
// AccountTests
//
// Created by Kiel Gillard on 4/11/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import XCTest
@testable import Account
import RSWeb
class FeedlyRefreshAccessTokenOperationTests: 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 TestRefreshTokenService: OAuthAccessTokenRefreshing {
var mockResult: Result<OAuthAuthorizationGrant, Error>?
var refreshAccessTokenExpectation: XCTestExpectation?
var parameterTester: ((String, OAuthAuthorizationClient) -> ())?
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
guard let result = mockResult else {
XCTFail("Missing mock result. Test may time out because the completion will not be called.")
return
}
parameterTester?(refreshToken, client)
DispatchQueue.main.async {
completionHandler(result)
self.refreshAccessTokenExpectation?.fulfill()
}
}
}
func testCancel() {
let service = TestRefreshTokenService()
service.refreshAccessTokenExpectation = expectation(description: "Did Call Refresh")
service.refreshAccessTokenExpectation?.isInverted = true
let client = support.makeMockOAuthClient()
let refresh = FeedlyRefreshAccessTokenOperation(account: account, service: service, oauthClient: client, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
refresh.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(refresh)
refresh.cancel()
waitForExpectations(timeout: 1)
XCTAssertTrue(refresh.isCancelled)
}
class TestRefreshTokenDelegate: FeedlyOperationDelegate {
var error: Error?
var didFailExpectation: XCTestExpectation?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
self.error = error
didFailExpectation?.fulfill()
}
}
func testMissingRefreshToken() {
support.removeCredentials(matching: .oauthRefreshToken, from: account)
let service = TestRefreshTokenService()
service.refreshAccessTokenExpectation = expectation(description: "Did Call Refresh")
service.refreshAccessTokenExpectation?.isInverted = true
let client = support.makeMockOAuthClient()
let refresh = FeedlyRefreshAccessTokenOperation(account: account, service: service, oauthClient: client, log: support.log)
let delegate = TestRefreshTokenDelegate()
delegate.didFailExpectation = expectation(description: "Did Fail")
refresh.delegate = delegate
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
refresh.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(refresh)
waitForExpectations(timeout: 1)
XCTAssertNotNil(delegate.error, "Should have failed with error.")
if let error = delegate.error {
switch error {
case let error as TransportError:
switch error {
case .httpError(status: let status):
XCTAssertEqual(status, 403, "Expected 403 Forbidden.")
default:
XCTFail("Expected 403 Forbidden")
}
default:
XCTFail("Expected \(TransportError.httpError(status: 403))")
}
}
}
func testRefreshTokenSuccess() {
let service = TestRefreshTokenService()
service.refreshAccessTokenExpectation = expectation(description: "Did Call Refresh")
let mockAccessToken = Credentials(type: .oauthAccessToken, username: "Test", secret: UUID().uuidString)
let mockRefreshToken = Credentials(type: .oauthRefreshToken, username: "Test", secret: UUID().uuidString)
let grant = OAuthAuthorizationGrant(accessToken: mockAccessToken, refreshToken: mockRefreshToken)
service.mockResult = .success(grant)
let client = support.makeMockOAuthClient()
service.parameterTester = { serviceRefreshToken, serviceClient in
if let accountRefreshToken = try! self.account.retrieveCredentials(type: .oauthRefreshToken) {
XCTAssertEqual(serviceRefreshToken, accountRefreshToken.secret)
} else {
XCTFail("Could not verify correct refresh token used.")
}
XCTAssertEqual(serviceClient, client)
}
let refresh = FeedlyRefreshAccessTokenOperation(account: account, service: service, oauthClient: client, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
refresh.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(refresh)
waitForExpectations(timeout: 1)
do {
let accessToken = try account.retrieveCredentials(type: .oauthAccessToken)
XCTAssertEqual(accessToken, mockAccessToken)
let refreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
XCTAssertEqual(refreshToken, mockRefreshToken)
} catch {
XCTFail("Could not verify refresh and access tokens because \(error).")
}
}
func testRefreshTokenFailure() {
let accessTokenBefore: Credentials
let refreshTokenBefore: Credentials
do {
guard let accessToken = try account.retrieveCredentials(type: .oauthAccessToken),
let refreshToken = try account.retrieveCredentials(type: .oauthRefreshToken) else {
XCTFail("Initial refresh and/or access token does not exist.")
return
}
accessTokenBefore = accessToken
refreshTokenBefore = refreshToken
} catch {
XCTFail("Caught error getting initial refresh and access tokens because \(error).")
return
}
let service = TestRefreshTokenService()
service.refreshAccessTokenExpectation = expectation(description: "Did Call Refresh")
service.mockResult = .failure(URLError(.timedOut))
let client = support.makeMockOAuthClient()
service.parameterTester = { serviceRefreshToken, serviceClient in
if let accountRefreshToken = try! self.account.retrieveCredentials(type: .oauthRefreshToken) {
XCTAssertEqual(serviceRefreshToken, accountRefreshToken.secret)
} else {
XCTFail("Could not verify correct refresh token used.")
}
XCTAssertEqual(serviceClient, client)
}
let refresh = FeedlyRefreshAccessTokenOperation(account: account, service: service, oauthClient: client, log: support.log)
// If this expectation is not fulfilled, the operation is not calling `didFinish`.
let completionExpectation = expectation(description: "Did Finish")
refresh.completionBlock = {
completionExpectation.fulfill()
}
OperationQueue.main.addOperation(refresh)
waitForExpectations(timeout: 1)
do {
let accessToken = try account.retrieveCredentials(type: .oauthAccessToken)
XCTAssertEqual(accessToken, accessTokenBefore)
let refreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
XCTAssertEqual(refreshToken, refreshTokenBefore)
} catch {
XCTFail("Could not verify refresh and access tokens because \(error).")
}
}
}

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,268 @@
//
// 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.accessToken,
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")
syncCompletionExpectation.isInverted = true
syncAll.syncCompletionHandler = { result in
switch result {
case .success:
XCTFail("Sync operation was cancelled, not successful.")
case .failure:
XCTFail("Sync operation should cancel silently.")
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.accessToken
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.accessToken,
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: 5)
}
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,292 @@
//
// 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
class FeedlyTestSupport {
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "FeedlyTests")
var accessToken = Credentials(type: .oauthAccessToken, username: "Test", secret: "t3st-access-tok3n")
var refreshToken = Credentials(type: .oauthRefreshToken, username: "Test", secret: "t3st-refresh-tok3n")
var transport = TestTransport()
func makeMockNetworkStack() -> (TestTransport, FeedlyAPICaller) {
let caller = FeedlyAPICaller(transport: transport, api: .sandbox)
caller.credentials = accessToken
return (transport, caller)
}
func makeTestAccount() -> Account {
let manager = TestAccountManager()
let account = manager.createAccount(type: .feedly, transport: transport)
do {
try account.storeCredentials(refreshToken)
// This must be done last or the account uses the refresh token for request Authorization!
try account.storeCredentials(accessToken)
} catch {
XCTFail("Unable to register mock credentials because \(error)")
}
return account
}
func makeMockOAuthClient() -> OAuthAuthorizationClient {
return OAuthAuthorizationClient(id: "test", redirectUri: "test://test/auth", state: nil, secret: "password")
}
func removeCredentials(matching type: CredentialsType, from account: Account) {
do {
try account.removeCredentials(type: type)
} catch {
XCTFail("Unable to remove \(type)")
}
}
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 {
// These should not throw when the keychain items are not found.
try testAccount.removeCredentials(type: .oauthAccessToken)
try testAccount.removeCredentials(type: .oauthRefreshToken)
} 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.topLevelWebFeeds
XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
let folderFeedIds = Set(folderFeeds.map { $0.webFeedID })
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.webFeedID, 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": []
}

File diff suppressed because one or more lines are too long

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 {
@ -33,43 +32,49 @@ final class TestTransport: Transport {
return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: nil)!
}
func cancelAll() { }
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

@ -17,10 +17,10 @@ public protocol ArticleFetcher {
func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock)
}
extension Feed: ArticleFetcher {
extension WebFeed: ArticleFetcher {
public func fetchArticles() -> Set<Article> {
return account?.fetchArticles(.feed(self)) ?? Set<Article>()
return account?.fetchArticles(.webFeed(self)) ?? Set<Article>()
}
public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) {
@ -29,7 +29,7 @@ extension Feed: ArticleFetcher {
callback(Set<Article>())
return
}
account.fetchArticlesAsync(.feed(self), callback)
account.fetchArticlesAsync(.webFeed(self), callback)
}
public func fetchUnreadArticles() -> Set<Article> {
@ -42,12 +42,12 @@ extension Feed: ArticleFetcher {
callback(Set<Article>())
return
}
account.fetchArticlesAsync(.feed(self)) { callback($0.unreadArticles()) }
account.fetchArticlesAsync(.webFeed(self)) { callback($0.unreadArticles()) }
}
}
extension Folder: ArticleFetcher {
public func fetchArticles() -> Set<Article> {
return fetchUnreadArticles()
}

View File

@ -19,25 +19,25 @@ extension Notification.Name {
public protocol Container: class {
var account: Account? { get }
var topLevelFeeds: Set<Feed> { get set }
var topLevelWebFeeds: Set<WebFeed> { get set }
var folders: Set<Folder>? { get set }
func hasAtLeastOneFeed() -> Bool
func hasAtLeastOneWebFeed() -> Bool
func objectIsChild(_ object: AnyObject) -> Bool
func hasChildFolder(with: String) -> Bool
func childFolder(with: String) -> Folder?
func removeFeed(_ feed: Feed)
func addFeed(_ feed: Feed)
func removeWebFeed(_ webFeed: WebFeed)
func addWebFeed(_ webFeed: WebFeed)
//Recursive  checks subfolders
func flattenedFeeds() -> Set<Feed>
func has(_ feed: Feed) -> Bool
func hasFeed(with feedID: String) -> Bool
func hasFeed(withURL url: String) -> Bool
func existingFeed(withFeedID: String) -> Feed?
func existingFeed(withURL url: String) -> Feed?
func flattenedWebFeeds() -> Set<WebFeed>
func has(_ webFeed: WebFeed) -> Bool
func hasWebFeed(with webFeedID: String) -> Bool
func hasWebFeed(withURL url: String) -> Bool
func existingWebFeed(withWebFeedID: String) -> WebFeed?
func existingWebFeed(withURL url: String) -> WebFeed?
func existingFolder(with name: String) -> Folder?
func existingFolder(withID: Int) -> Folder?
@ -46,8 +46,8 @@ public protocol Container: class {
public extension Container {
func hasAtLeastOneFeed() -> Bool {
return topLevelFeeds.count > 0
func hasAtLeastOneWebFeed() -> Bool {
return topLevelWebFeeds.count > 0
}
func hasChildFolder(with name: String) -> Bool {
@ -67,8 +67,8 @@ public extension Container {
}
func objectIsChild(_ object: AnyObject) -> Bool {
if let feed = object as? Feed {
return topLevelFeeds.contains(feed)
if let feed = object as? WebFeed {
return topLevelWebFeeds.contains(feed)
}
if let folder = object as? Folder {
return folders?.contains(folder) ?? false
@ -76,40 +76,40 @@ public extension Container {
return false
}
func flattenedFeeds() -> Set<Feed> {
var feeds = Set<Feed>()
feeds.formUnion(topLevelFeeds)
func flattenedWebFeeds() -> Set<WebFeed> {
var feeds = Set<WebFeed>()
feeds.formUnion(topLevelWebFeeds)
if let folders = folders {
for folder in folders {
feeds.formUnion(folder.flattenedFeeds())
feeds.formUnion(folder.flattenedWebFeeds())
}
}
return feeds
}
func hasFeed(with feedID: String) -> Bool {
return existingFeed(withFeedID: feedID) != nil
func hasWebFeed(with webFeedID: String) -> Bool {
return existingWebFeed(withWebFeedID: webFeedID) != nil
}
func hasFeed(withURL url: String) -> Bool {
return existingFeed(withURL: url) != nil
func hasWebFeed(withURL url: String) -> Bool {
return existingWebFeed(withURL: url) != nil
}
func has(_ feed: Feed) -> Bool {
return flattenedFeeds().contains(feed)
func has(_ webFeed: WebFeed) -> Bool {
return flattenedWebFeeds().contains(webFeed)
}
func existingFeed(withFeedID feedID: String) -> Feed? {
for feed in flattenedFeeds() {
if feed.feedID == feedID {
func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? {
for feed in flattenedWebFeeds() {
if feed.webFeedID == webFeedID {
return feed
}
}
return nil
}
func existingFeed(withURL url: String) -> Feed? {
for feed in flattenedFeeds() {
func existingWebFeed(withURL url: String) -> WebFeed? {
for feed in flattenedWebFeeds() {
if feed.url == url {
return feed
}

View File

@ -23,7 +23,7 @@ public enum CredentialsType: String {
case oauthRefreshToken = "oauthRefreshToken"
}
public struct Credentials {
public struct Credentials: Equatable {
public let type: CredentialsType
public let username: String
public let secret: String

View File

@ -20,11 +20,12 @@ public struct CredentialsManager {
}()
public static func storeCredentials(_ credentials: Credentials, server: String) throws {
var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: credentials.username,
kSecAttrServer as String: server]
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecAttrAccount as String: credentials.username,
kSecAttrServer as String: server]
if credentials.type != .basic {
query[kSecAttrSecurityDomain as String] = credentials.type.rawValue
}
@ -32,26 +33,25 @@ public struct CredentialsManager {
if let securityGroup = keychainGroup {
query[kSecAttrAccessGroup as String] = securityGroup
}
let secretData = credentials.secret.data(using: String.Encoding.utf8)!
let attributes: [String: Any] = [kSecValueData as String: secretData]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
query[kSecValueData as String] = secretData
let status = SecItemAdd(query as CFDictionary, nil)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
case errSecDuplicateItem:
break
default:
throw CredentialsError.unhandledError(status: status)
}
guard status == errSecItemNotFound else {
return
}
var deleteQuery = query
deleteQuery.removeValue(forKey: kSecAttrAccessible as String)
SecItemDelete(deleteQuery as CFDictionary)
query[kSecValueData as String] = secretData
let addStatus = SecItemAdd(query as CFDictionary, nil)
if addStatus != errSecSuccess {
throw CredentialsError.unhandledError(status: status)

View File

@ -11,14 +11,14 @@ import Articles
import RSParser
public extension Notification.Name {
static let FeedSettingDidChange = Notification.Name(rawValue: "FeedSettingDidChangeNotification")
static let WebFeedSettingDidChange = Notification.Name(rawValue: "FeedSettingDidChangeNotification")
}
public extension Feed {
public extension WebFeed {
static let FeedSettingUserInfoKey = "feedSetting"
static let WebFeedSettingUserInfoKey = "feedSetting"
struct FeedSettingKey {
struct WebFeedSettingKey {
public static let homePageURL = "homePageURL"
public static let iconURL = "iconURL"
public static let faviconURL = "faviconURL"
@ -30,7 +30,7 @@ public extension Feed {
}
}
extension Feed {
extension WebFeed {
func takeSettings(from parsedFeed: ParsedFeed) {
iconURL = parsedFeed.iconURL
@ -40,9 +40,9 @@ extension Feed {
authors = Author.authorsWithParsedAuthors(parsedFeed.authors)
}
func postFeedSettingDidChangeNotification(_ codingKey: FeedMetadata.CodingKeys) {
let userInfo = [Feed.FeedSettingUserInfoKey: codingKey.stringValue]
NotificationCenter.default.post(name: .FeedSettingDidChange, object: self, userInfo: userInfo)
func postFeedSettingDidChangeNotification(_ codingKey: WebFeedMetadata.CodingKeys) {
let userInfo = [WebFeed.WebFeedSettingUserInfoKey: codingKey.stringValue]
NotificationCenter.default.post(name: .WebFeedSettingDidChange, object: self, userInfo: userInfo)
}
}
@ -56,8 +56,8 @@ public extension Article {
return manager.existingAccount(with: accountID)
}
var feed: Feed? {
return account?.existingFeed(withFeedID: feedID)
var webFeed: WebFeed? {
return account?.existingWebFeed(withWebFeedID: webFeedID)
}
}

View File

@ -1,21 +0,0 @@
//
// DeepLinkProvider.swift
// Account
//
// Created by Maurice Parker on 10/3/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public enum DeepLinkKey: String {
case accountID = "accountID"
case accountName = "accountName"
case feedID = "feedID"
case articleID = "articleID"
case folderName = "folderName"
}
public protocol DeepLinkProvider {
var deepLinkUserInfo: [AnyHashable : Any] { get }
}

View File

@ -1,277 +1,14 @@
//
// Feed.swift
// NetNewsWire
// Account
//
// Created by Brent Simmons on 7/1/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
// Created by Maurice Parker on 11/15/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSWeb
import Articles
public final class Feed: DisplayNameProvider, Renamable, UnreadCountProvider, DeepLinkProvider, Hashable {
public protocol Feed: FeedIdentifiable, ArticleFetcher, DisplayNameProvider, UnreadCountProvider {
public weak var account: Account?
public let url: String
public var feedID: String {
get {
return metadata.feedID
}
set {
metadata.feedID = newValue
}
}
public var homePageURL: String? {
get {
return metadata.homePageURL
}
set {
if let url = newValue {
metadata.homePageURL = url.rs_normalizedURL()
}
else {
metadata.homePageURL = nil
}
}
}
// Note: this is available only if the icon URL was available in the feed.
// The icon URL is a JSON-Feed-only feature.
// Otherwise we find an icon URL via other means, but we dont store it
// as part of feed metadata.
public var iconURL: String? {
get {
return metadata.iconURL
}
set {
metadata.iconURL = newValue
}
}
// Note: this is available only if the favicon URL was available in the feed.
// The favicon URL is a JSON-Feed-only feature.
// Otherwise we find a favicon URL via other means, but we dont store it
// as part of feed metadata.
public var faviconURL: String? {
get {
return metadata.faviconURL
}
set {
metadata.faviconURL = newValue
}
}
public var name: String?
public var authors: Set<Author>? {
get {
if let authorsArray = metadata.authors {
return Set(authorsArray)
}
return nil
}
set {
if let authorsSet = newValue {
metadata.authors = Array(authorsSet)
}
else {
metadata.authors = nil
}
}
}
public var editedName: String? {
// Dont let editedName == ""
get {
guard let s = metadata.editedName, !s.isEmpty else {
return nil
}
return s
}
set {
if newValue != editedName {
if let valueToSet = newValue, !valueToSet.isEmpty {
metadata.editedName = valueToSet
}
else {
metadata.editedName = nil
}
postDisplayNameDidChangeNotification()
}
}
}
public var conditionalGetInfo: HTTPConditionalGetInfo? {
get {
return metadata.conditionalGetInfo
}
set {
metadata.conditionalGetInfo = newValue
}
}
public var contentHash: String? {
get {
return metadata.contentHash
}
set {
metadata.contentHash = newValue
}
}
public var isNotifyAboutNewArticles: Bool? {
get {
return metadata.isNotifyAboutNewArticles
}
set {
metadata.isNotifyAboutNewArticles = newValue
}
}
public var isArticleExtractorAlwaysOn: Bool? {
get {
return metadata.isArticleExtractorAlwaysOn
}
set {
metadata.isArticleExtractorAlwaysOn = newValue
}
}
public var subscriptionID: String? {
get {
return metadata.subscriptionID
}
set {
metadata.subscriptionID = newValue
}
}
// Folder Name: Sync Service Relationship ID
public var folderRelationship: [String: String]? {
get {
return metadata.folderRelationship
}
set {
metadata.folderRelationship = newValue
}
}
// MARK: - DisplayNameProvider
public var nameForDisplay: String {
if let s = editedName, !s.isEmpty {
return s
}
if let s = name, !s.isEmpty {
return s
}
return NSLocalizedString("Untitled", comment: "Feed name")
}
// MARK: - Renamable
public func rename(to newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
guard let account = account else { return }
account.renameFeed(self, to: newName, completion: completion)
}
// MARK: - PathIDUserInfoProvider
public var deepLinkUserInfo: [AnyHashable : Any] {
return [
DeepLinkKey.accountID.rawValue: account?.accountID ?? "",
DeepLinkKey.accountName.rawValue: account?.nameForDisplay ?? "",
DeepLinkKey.feedID.rawValue: feedID
]
}
// MARK: - UnreadCountProvider
public var unreadCount: Int {
get {
return account?.unreadCount(for: self) ?? 0
}
set {
if unreadCount == newValue {
return
}
account?.setUnreadCount(newValue, for: self)
postUnreadCountDidChangeNotification()
}
}
var metadata: FeedMetadata
// MARK: - Private
private let accountID: String // Used for hashing and equality; account may turn nil
// MARK: - Init
init(account: Account, url: String, metadata: FeedMetadata) {
self.account = account
self.accountID = account.accountID
self.url = url
self.metadata = metadata
}
// MARK: - Debug
public func debugDropConditionalGetInfo() {
conditionalGetInfo = nil
contentHash = nil
}
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(feedID)
hasher.combine(accountID)
}
// MARK: - Equatable
public class func ==(lhs: Feed, rhs: Feed) -> Bool {
return lhs.feedID == rhs.feedID && lhs.accountID == rhs.accountID
}
}
// MARK: - OPMLRepresentable
extension Feed: OPMLRepresentable {
public func OPMLString(indentLevel: Int, strictConformance: Bool) -> String {
// https://github.com/brentsimmons/NetNewsWire/issues/527
// Dont use nameForDisplay because that can result in a feed name "Untitled" written to disk,
// which NetNewsWire may take later to be the actual name.
var nameToUse = editedName
if nameToUse == nil {
nameToUse = name
}
if nameToUse == nil {
nameToUse = ""
}
let escapedName = nameToUse!.rs_stringByEscapingSpecialXMLCharacters()
var escapedHomePageURL = ""
if let homePageURL = homePageURL {
escapedHomePageURL = homePageURL.rs_stringByEscapingSpecialXMLCharacters()
}
let escapedFeedURL = url.rs_stringByEscapingSpecialXMLCharacters()
var s = "<outline text=\"\(escapedName)\" title=\"\(escapedName)\" description=\"\" type=\"rss\" version=\"RSS\" htmlUrl=\"\(escapedHomePageURL)\" xmlUrl=\"\(escapedFeedURL)\"/>\n"
s = s.rs_string(byPrependingNumberOfTabs: indentLevel)
return s
}
}
extension Set where Element == Feed {
func feedIDs() -> Set<String> {
return Set<String>(map { $0.feedID })
}
}

View File

@ -0,0 +1,83 @@
//
// ArticleFetcherType.swift
// Account
//
// Created by Maurice Parker on 11/13/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public protocol FeedIdentifiable {
var feedID: FeedIdentifier? { get }
}
public enum FeedIdentifier: CustomStringConvertible {
case smartFeed(String) // String is a unique identifier
case script(String) // String is a unique identifier
case webFeed(String, String) // accountID, webFeedID
case folder(String, String) // accountID, folderName
public var description: String {
switch self {
case .smartFeed(let id):
return "smartFeed: \(id)"
case .script(let id):
return "script: \(id)"
case .webFeed(let accountID, let webFeedID):
return "feed: \(accountID)_\(webFeedID)"
case .folder(let accountID, let folderName):
return "folder: \(accountID)_\(folderName)"
}
}
public var userInfo: [AnyHashable: Any] {
switch self {
case .smartFeed(let id):
return [
"type": "smartFeed",
"id": id
]
case .script(let id):
return [
"type": "script",
"id": id
]
case .webFeed(let accountID, let webFeedID):
return [
"type": "feed",
"accountID": accountID,
"webFeedID": webFeedID
]
case .folder(let accountID, let folderName):
return [
"type": "folder",
"accountID": accountID,
"folderName": folderName
]
}
}
public init?(userInfo: [AnyHashable: Any]) {
guard let type = userInfo["type"] as? String else { return nil }
switch type {
case "smartFeed":
guard let id = userInfo["id"] as? String else { return nil }
self = FeedIdentifier.smartFeed(id)
case "script":
guard let id = userInfo["id"] as? String else { return nil }
self = FeedIdentifier.script(id)
case "feed":
guard let accountID = userInfo["accountID"] as? String, let webFeedID = userInfo["webFeedID"] as? String else { return nil }
self = FeedIdentifier.webFeed(accountID, webFeedID)
case "folder":
guard let accountID = userInfo["accountID"] as? String, let folderName = userInfo["folderName"] as? String else { return nil }
self = FeedIdentifier.folder(accountID, folderName)
default:
return nil
}
}
}

View File

@ -26,7 +26,6 @@ final class FeedbinAPICaller: NSObject {
static let subscriptions = "subscriptions"
static let tags = "tags"
static let taggings = "taggings"
static let icons = "icons"
static let unreadEntries = "unreadEntries"
static let starredEntries = "starredEntries"
}
@ -42,6 +41,10 @@ final class FeedbinAPICaller: NSObject {
self.transport = transport
}
func cancelAll() {
transport.cancelAll()
}
func validateCredentials(completion: @escaping (Result<Credentials?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("authentication.json")
@ -145,9 +148,11 @@ final class FeedbinAPICaller: NSObject {
func retrieveSubscriptions(completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json")
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)!
callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")]
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.subscriptions]
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
let request = URLRequest(url: callComponents.url!, credentials: credentials, conditionalGet: conditionalGet)
transport.send(request: request, resultType: [FeedbinSubscription].self) { result in
@ -165,8 +170,10 @@ final class FeedbinAPICaller: NSObject {
func createSubscription(url: String, completion: @escaping (Result<CreateSubscriptionResult, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json")
var request = URLRequest(url: callURL, credentials: credentials)
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)!
callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")]
var request = URLRequest(url: callComponents.url!, credentials: credentials)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
let payload: Data
@ -268,7 +275,7 @@ final class FeedbinAPICaller: NSObject {
}
func createTagging(feedID: Int, name: String, completion: @escaping (Result<Int, Error>) -> Void) {
func createTagging(webFeedID: Int, name: String, completion: @escaping (Result<Int, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("taggings.json")
var request = URLRequest(url: callURL, credentials: credentials)
@ -276,7 +283,7 @@ final class FeedbinAPICaller: NSObject {
let payload: Data
do {
payload = try JSONEncoder().encode(FeedbinCreateTagging(feedID: feedID, name: name))
payload = try JSONEncoder().encode(FeedbinCreateTagging(feedID: webFeedID, name: name))
} catch {
completion(.failure(error))
return
@ -309,26 +316,6 @@ final class FeedbinAPICaller: NSObject {
transport.send(request: request, method: HTTPMethod.delete, completion: completion)
}
func retrieveIcons(completion: @escaping (Result<[FeedbinIcon]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("icons.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.icons]
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
transport.send(request: request, resultType: [FeedbinIcon].self) { result in
switch result {
case .success(let (response, icons)):
self.storeConditionalGet(key: ConditionalGetKeys.icons, headers: response.allHeaderFields)
completion(.success(icons))
case .failure(let error):
completion(.failure(error))
}
}
}
func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([FeedbinEntry]?), Error>) -> Void) {
guard !articleIDs.isEmpty else {
@ -390,7 +377,7 @@ final class FeedbinAPICaller: NSObject {
}
func retrieveEntries(completion: @escaping (Result<([FeedbinEntry]?, String?, Int?), Error>) -> Void) {
func retrieveEntries(completion: @escaping (Result<([FeedbinEntry]?, String?, Date?, Int?), Error>) -> Void) {
let since: Date = {
if let lastArticleFetch = accountMetadata?.lastArticleFetch {
@ -416,14 +403,12 @@ final class FeedbinAPICaller: NSObject {
case .success(let (response, entries)):
let dateInfo = HTTPDateInfo(urlResponse: response)
self.accountMetadata?.lastArticleFetch = dateInfo?.date
let pagingInfo = HTTPLinkPagingInfo(urlResponse: response)
let lastPageNumber = self.extractPageNumber(link: pagingInfo.lastPage)
completion(.success((entries, pagingInfo.nextPage, lastPageNumber)))
completion(.success((entries, pagingInfo.nextPage, dateInfo?.date, lastPageNumber)))
case .failure(let error):
self.accountMetadata?.lastArticleFetch = nil
completion(.failure(error))
}
@ -449,7 +434,6 @@ final class FeedbinAPICaller: NSObject {
completion(.success((entries, pagingInfo.nextPage)))
case .failure(let error):
self.accountMetadata?.lastArticleFetch = nil
completion(.failure(error))
}

View File

@ -15,6 +15,7 @@ import os.log
public enum FeedbinAccountDelegateError: String, Error {
case invalidParameter = "There was an invalid parameter passed."
case unknown = "An unknown error occurred."
}
final class FeedbinAccountDelegate: AccountDelegate {
@ -72,24 +73,27 @@ final class FeedbinAccountDelegate: AccountDelegate {
var refreshProgress = DownloadProgress(numberOfTasks: 0)
func cancelAll(for account: Account) {
caller.cancelAll()
}
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
refreshProgress.addToNumberOfTasksAndRemaining(6)
refreshProgress.addToNumberOfTasksAndRemaining(5)
refreshAccount(account) { result in
switch result {
case .success():
self.sendArticleStatus(for: account) {
self.refreshArticleStatus(for: account) {
self.refreshArticles(account) {
self.refreshMissingArticles(account) {
self.refreshProgress.clear()
DispatchQueue.main.async {
completion(.success(()))
}
}
self.refreshArticlesAndStatuses(account) { result in
switch result {
case .success():
completion(.success(()))
case .failure(let error):
DispatchQueue.main.async {
self.refreshProgress.clear()
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
}
}
}
@ -106,8 +110,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
retrieveCredentialsIfNecessary(account)
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
os_log(.debug, log: log, "Sending article statuses...")
@ -118,41 +121,58 @@ final class FeedbinAccountDelegate: AccountDelegate {
let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false }
let group = DispatchGroup()
var errorOccurred = false
group.enter()
sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) {
sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.enter()
sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) {
sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.enter()
sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) {
sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.enter()
sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) {
sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done sending article statuses.")
completion()
if errorOccurred {
completion(.failure(FeedbinAccountDelegateError.unknown))
} else {
completion(.success(()))
}
}
}
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
retrieveCredentialsIfNecessary(account)
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
os_log(.debug, log: log, "Refreshing article statuses...")
let group = DispatchGroup()
var errorOccurred = false
group.enter()
caller.retrieveUnreadEntries() { result in
switch result {
@ -160,6 +180,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
self.syncArticleReadState(account: account, articleIDs: articleIDs)
group.leave()
case .failure(let error):
errorOccurred = true
os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription)
group.leave()
}
@ -173,6 +194,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
self.syncArticleStarredState(account: account, articleIDs: articleIDs)
group.leave()
case .failure(let error):
errorOccurred = true
os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription)
group.leave()
}
@ -181,13 +203,16 @@ final class FeedbinAccountDelegate: AccountDelegate {
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done refreshing article statuses.")
completion()
if errorOccurred {
completion(.failure(FeedbinAccountDelegateError.unknown))
} else {
completion(.success(()))
}
}
}
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
var fileData: Data?
@ -234,7 +259,6 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
if let folder = account.ensureFolder(with: name) {
completion(.success(folder))
} else {
@ -243,9 +267,8 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
guard folder.hasAtLeastOneFeed() else {
guard folder.hasAtLeastOneWebFeed() else {
folder.name = name
return
}
@ -271,10 +294,9 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
// Feedbin uses tags and if at least one feed isn't tagged, then the folder doesn't exist on their system
guard folder.hasAtLeastOneFeed() else {
guard folder.hasAtLeastOneWebFeed() else {
account.removeFolder(folder)
completion(.success(()))
return
@ -282,7 +304,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
let group = DispatchGroup()
for feed in folder.topLevelFeeds {
for feed in folder.topLevelWebFeeds {
if feed.folderRelationship?.count ?? 0 > 1 {
@ -314,7 +336,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
switch result {
case .success:
DispatchQueue.main.async {
account.clearFeedMetadata(feed)
account.clearWebFeedMetadata(feed)
}
case .failure(let error):
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
@ -334,8 +356,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.createSubscription(url: url) { result in
@ -367,8 +388,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
@ -395,7 +415,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if feed.folderRelationship?.count ?? 0 > 1 {
deleteTagging(for: account, with: feed, from: container, completion: completion)
} else {
@ -403,15 +423,14 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
}
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if from is Account {
addFeed(for: account, with: feed, to: to, completion: completion)
addWebFeed(for: account, with: feed, to: to, completion: completion)
} else {
deleteTagging(for: account, with: feed, from: from) { result in
switch result {
case .success:
self.addFeed(for: account, with: feed, to: to, completion: completion)
self.addWebFeed(for: account, with: feed, to: to, completion: completion)
case .failure(let error):
completion(.failure(error))
}
@ -419,19 +438,18 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
}
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if let folder = container as? Folder, let feedID = Int(feed.feedID) {
if let folder = container as? Folder, let webFeedID = Int(feed.webFeedID) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.createTagging(feedID: feedID, name: folder.name ?? "") { result in
caller.createTagging(webFeedID: webFeedID, name: folder.name ?? "") { result in
self.refreshProgress.completeTask()
switch result {
case .success(let taggingID):
DispatchQueue.main.async {
self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: String(taggingID))
account.removeFeed(feed)
folder.addFeed(feed)
account.removeWebFeed(feed)
folder.addWebFeed(feed)
completion(.success(()))
}
case .failure(let error):
@ -452,11 +470,10 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if let existingFeed = account.existingFeed(withURL: feed.url) {
account.addFeed(existingFeed, to: container) { result in
if let existingFeed = account.existingWebFeed(withURL: feed.url) {
account.addWebFeed(existingFeed, to: container) { result in
switch result {
case .success:
completion(.success(()))
@ -465,7 +482,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
}
} else {
createFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in
createWebFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in
switch result {
case .success:
completion(.success(()))
@ -478,16 +495,15 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
retrieveCredentialsIfNecessary(account)
let group = DispatchGroup()
for feed in folder.topLevelFeeds {
for feed in folder.topLevelWebFeeds {
folder.topLevelFeeds.remove(feed)
folder.topLevelWebFeeds.remove(feed)
group.enter()
restoreFeed(for: account, feed: feed, container: folder) { result in
restoreWebFeed(for: account, feed: feed, container: folder) { result in
group.leave()
switch result {
case .success:
@ -507,7 +523,6 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
retrieveCredentialsIfNecessary(account)
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
@ -515,7 +530,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
database.insertStatuses(syncStatuses)
if database.selectPendingCount() > 100 {
sendArticleStatus(for: account) {}
sendArticleStatus(for: account) { _ in }
}
return account.update(articles, statusKey: statusKey, flag: flag)
@ -526,6 +541,9 @@ final class FeedbinAccountDelegate: AccountDelegate {
credentials = try? account.retrieveCredentials(type: .basic)
}
func accountWillBeDeleted(_ account: Account) {
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result<Credentials?, Error>) -> Void) {
let caller = FeedbinAPICaller(transport: transport)
@ -598,26 +616,14 @@ private extension FeedbinAccountDelegate {
switch result {
case .success(let taggings):
self.refreshProgress.completeTask()
self.caller.retrieveIcons { result in
switch result {
case .success(let icons):
BatchUpdate.shared.perform {
self.syncFolders(account, tags)
self.syncFeeds(account, subscriptions)
self.syncFeedFolderRelationship(account, taggings)
self.syncFavicons(account, icons)
}
self.refreshProgress.completeTask()
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
BatchUpdate.shared.perform {
self.syncFolders(account, tags)
self.syncFeeds(account, subscriptions)
self.syncFeedFolderRelationship(account, taggings)
}
self.refreshProgress.completeTask()
completion(.success(()))
case .failure(let error):
completion(.failure(error))
@ -639,6 +645,49 @@ private extension FeedbinAccountDelegate {
}
func refreshArticlesAndStatuses(_ account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
self.sendArticleStatus(for: account) { result in
switch result {
case .success:
self.refreshArticleStatus(for: account) { result in
switch result {
case .success:
self.refreshArticles(account) { result in
switch result {
case .success:
self.refreshMissingArticles(account) { result in
switch result {
case .success:
DispatchQueue.main.async {
self.refreshProgress.clear()
completion(.success(()))
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
// This function can be deleted if Feedbin updates their taggings.json service to
// show a change when a tag is renamed.
func forceExpireFolderFeedRelationship(_ account: Account, _ tags: [FeedbinTag]?) {
@ -675,8 +724,8 @@ private extension FeedbinAccountDelegate {
if let folders = account.folders {
folders.forEach { folder in
if !tagNames.contains(folder.name ?? "") {
for feed in folder.topLevelFeeds {
account.addFeed(feed)
for feed in folder.topLevelWebFeeds {
account.addWebFeed(feed)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
}
account.removeFolder(folder)
@ -713,17 +762,17 @@ private extension FeedbinAccountDelegate {
// Remove any feeds that are no longer in the subscriptions
if let folders = account.folders {
for folder in folders {
for feed in folder.topLevelFeeds {
if !subFeedIds.contains(feed.feedID) {
folder.removeFeed(feed)
for feed in folder.topLevelWebFeeds {
if !subFeedIds.contains(feed.webFeedID) {
folder.removeWebFeed(feed)
}
}
}
}
for feed in account.topLevelFeeds {
if !subFeedIds.contains(feed.feedID) {
account.removeFeed(feed)
for feed in account.topLevelWebFeeds {
if !subFeedIds.contains(feed.webFeedID) {
account.removeWebFeed(feed)
}
}
@ -733,12 +782,14 @@ private extension FeedbinAccountDelegate {
let subFeedId = String(subscription.feedID)
if let feed = account.existingFeed(withFeedID: subFeedId) {
if let feed = account.existingWebFeed(withWebFeedID: subFeedId) {
feed.name = subscription.name
// If the name has been changed on the server remove the locally edited name
feed.editedName = nil
feed.homePageURL = subscription.homePageURL
feed.subscriptionID = String(subscription.subscriptionID)
feed.faviconURL = subscription.jsonFeed?.favicon
feed.iconURL = subscription.jsonFeed?.icon
}
else {
subscriptionsToAdd.insert(subscription)
@ -747,9 +798,9 @@ private extension FeedbinAccountDelegate {
// Actually add subscriptions all in one go, so we dont trigger various rebuilding things that Account does.
subscriptionsToAdd.forEach { subscription in
let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL)
let feed = account.createWebFeed(with: subscription.name, url: subscription.url, webFeedID: String(subscription.feedID), homePageURL: subscription.homePageURL)
feed.subscriptionID = String(subscription.subscriptionID)
account.addFeed(feed)
account.addWebFeed(feed)
}
}
@ -788,25 +839,25 @@ private extension FeedbinAccountDelegate {
let taggingFeedIDs = groupedTaggings.map { String($0.feedID) }
// Move any feeds not in the folder to the account
for feed in folder.topLevelFeeds {
if !taggingFeedIDs.contains(feed.feedID) {
folder.removeFeed(feed)
for feed in folder.topLevelWebFeeds {
if !taggingFeedIDs.contains(feed.webFeedID) {
folder.removeWebFeed(feed)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
account.addFeed(feed)
account.addWebFeed(feed)
}
}
// Add any feeds not in the folder
let folderFeedIds = folder.topLevelFeeds.map { $0.feedID }
let folderFeedIds = folder.topLevelWebFeeds.map { $0.webFeedID }
for tagging in groupedTaggings {
let taggingFeedID = String(tagging.feedID)
if !folderFeedIds.contains(taggingFeedID) {
guard let feed = account.existingFeed(withFeedID: taggingFeedID) else {
guard let feed = account.existingWebFeed(withWebFeedID: taggingFeedID) else {
continue
}
saveFolderRelationship(for: feed, withFolderName: folderName, id: String(tagging.taggingID))
folder.addFeed(feed)
folder.addWebFeed(feed)
}
}
@ -815,42 +866,24 @@ private extension FeedbinAccountDelegate {
let taggedFeedIDs = Set(taggings.map { String($0.feedID) })
// Remove all feeds from the account container that have a tag
for feed in account.topLevelFeeds {
if taggedFeedIDs.contains(feed.feedID) {
account.removeFeed(feed)
for feed in account.topLevelWebFeeds {
if taggedFeedIDs.contains(feed.webFeedID) {
account.removeWebFeed(feed)
}
}
}
func syncFavicons(_ account: Account, _ icons: [FeedbinIcon]?) {
guard let icons = icons else { return }
os_log(.debug, log: log, "Syncing favicons with %ld icons.", icons.count)
let iconDict = Dictionary(uniqueKeysWithValues: icons.map { ($0.host, $0.url) } )
for feed in account.flattenedFeeds() {
for (key, value) in iconDict {
if feed.homePageURL?.contains(key) ?? false {
feed.faviconURL = value
break
}
}
}
}
func sendArticleStatuses(_ statuses: [SyncStatus],
apiCall: ([Int], @escaping (Result<Void, Error>) -> Void) -> Void,
completion: @escaping (() -> Void)) {
completion: @escaping ((Result<Void, Error>) -> Void)) {
guard !statuses.isEmpty else {
completion()
completion(.success(()))
return
}
let group = DispatchGroup()
var errorOccurred = false
let articleIDs = statuses.compactMap { Int($0.articleID) }
let articleIDGroups = articleIDs.chunked(into: 1000)
@ -863,6 +896,7 @@ private extension FeedbinAccountDelegate {
self.database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } )
group.leave()
case .failure(let error):
errorOccurred = true
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
self.database.resetSelectedForProcessing(articleIDGroup.map { String($0) } )
group.leave()
@ -872,13 +906,17 @@ private extension FeedbinAccountDelegate {
}
group.notify(queue: DispatchQueue.main) {
completion()
if errorOccurred {
completion(.failure(FeedbinAccountDelegateError.unknown))
} else {
completion(.success(()))
}
}
}
func renameFolderRelationship(for account: Account, fromName: String, toName: String) {
for feed in account.flattenedFeeds() {
for feed in account.flattenedWebFeeds() {
if var folderRelationship = feed.folderRelationship {
let relationship = folderRelationship[fromName]
folderRelationship[fromName] = nil
@ -888,14 +926,14 @@ private extension FeedbinAccountDelegate {
}
}
func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) {
func clearFolderRelationship(for feed: WebFeed, withFolderName folderName: String) {
if var folderRelationship = feed.folderRelationship {
folderRelationship[folderName] = nil
feed.folderRelationship = folderRelationship
}
}
func saveFolderRelationship(for feed: Feed, withFolderName folderName: String, id: String) {
func saveFolderRelationship(for feed: WebFeed, withFolderName folderName: String, id: String) {
if var folderRelationship = feed.folderRelationship {
folderRelationship[folderName] = id
feed.folderRelationship = folderRelationship
@ -904,7 +942,7 @@ private extension FeedbinAccountDelegate {
}
}
func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result<Feed, Error>) -> Void) {
func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice], completion: @escaping (Result<WebFeed, Error>) -> Void) {
let feedSpecifiers: [FeedSpecifier] = choices.map { choice in
let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink
@ -914,7 +952,7 @@ private extension FeedbinAccountDelegate {
if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) {
if let bestSubscription = choices.filter({ bestSpecifier.urlString == $0.url }).first {
createFeed(for: account, url: bestSubscription.url, name: name, container: container, completion: completion)
createWebFeed(for: account, url: bestSubscription.url, name: name, container: container, completion: completion)
} else {
DispatchQueue.main.async {
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
@ -928,19 +966,20 @@ private extension FeedbinAccountDelegate {
}
func createFeed( account: Account, subscription sub: FeedbinSubscription, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
func createFeed( account: Account, subscription sub: FeedbinSubscription, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
DispatchQueue.main.async {
let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL)
let feed = account.createWebFeed(with: sub.name, url: sub.url, webFeedID: String(sub.feedID), homePageURL: sub.homePageURL)
feed.subscriptionID = String(sub.subscriptionID)
feed.iconURL = sub.jsonFeed?.icon
feed.faviconURL = sub.jsonFeed?.favicon
account.addFeed(feed, to: container) { result in
account.addWebFeed(feed, to: container) { result in
switch result {
case .success:
if let name = name {
account.renameFeed(feed, to: name) { result in
account.renameWebFeed(feed, to: name) { result in
switch result {
case .success:
self.initialFeedDownload(account: account, feed: feed, completion: completion)
@ -960,28 +999,51 @@ private extension FeedbinAccountDelegate {
}
func initialFeedDownload( account: Account, feed: Feed, completion: @escaping (Result<Feed, Error>) -> Void) {
func initialFeedDownload( account: Account, feed: WebFeed, completion: @escaping (Result<WebFeed, Error>) -> Void) {
// refreshArticles is being reused and will clear one of the tasks for us
refreshProgress.addToNumberOfTasksAndRemaining(4)
// Download the initial articles
self.caller.retrieveEntries(feedID: feed.feedID) { result in
self.caller.retrieveEntries(feedID: feed.webFeedID) { result in
self.refreshProgress.completeTask()
switch result {
case .success(let (entries, page)):
self.processEntries(account: account, entries: entries) {
self.refreshArticleStatus(for: account) {
self.refreshArticles(account, page: page) {
self.refreshProgress.completeTask()
self.refreshMissingArticles(account) {
self.refreshProgress.completeTask()
DispatchQueue.main.async {
completion(.success(feed))
self.refreshArticleStatus(for: account) { result in
switch result {
case .success:
self.refreshArticles(account, page: page, updateFetchDate: nil) { result in
switch result {
case .success:
self.refreshProgress.completeTask()
self.refreshMissingArticles(account) { result in
switch result {
case .success:
self.refreshProgress.completeTask()
DispatchQueue.main.async {
completion(.success(feed))
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
@ -994,14 +1056,14 @@ private extension FeedbinAccountDelegate {
}
func refreshArticles(_ account: Account, completion: @escaping (() -> Void)) {
func refreshArticles(_ account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
os_log(.debug, log: log, "Refreshing articles...")
caller.retrieveEntries() { result in
switch result {
case .success(let (entries, page, lastPageNumber)):
case .success(let (entries, page, updateFetchDate, lastPageNumber)):
if let last = lastPageNumber {
self.refreshProgress.addToNumberOfTasksAndRemaining(last - 1)
@ -1010,25 +1072,30 @@ private extension FeedbinAccountDelegate {
self.processEntries(account: account, entries: entries) {
self.refreshProgress.completeTask()
self.refreshArticles(account, page: page) {
self.refreshArticles(account, page: page, updateFetchDate: updateFetchDate) { result in
os_log(.debug, log: self.log, "Done refreshing articles.")
completion()
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
case .failure(let error):
os_log(.error, log: self.log, "Refresh articles failed: %@.", error.localizedDescription)
completion()
completion(.failure(error))
}
}
}
func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) {
func refreshMissingArticles(_ account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
os_log(.debug, log: log, "Refreshing missing articles...")
let group = DispatchGroup()
var errorOccurred = false
let fetchedArticleIDs = account.fetchArticleIDsForStatusesWithoutArticles()
let articleIDs = Array(fetchedArticleIDs)
@ -1046,6 +1113,7 @@ private extension FeedbinAccountDelegate {
}
case .failure(let error):
errorOccurred = true
os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription)
group.leave()
}
@ -1055,13 +1123,20 @@ private extension FeedbinAccountDelegate {
group.notify(queue: DispatchQueue.main) {
self.refreshProgress.completeTask()
os_log(.debug, log: self.log, "Done refreshing missing articles.")
completion()
if errorOccurred {
completion(.failure(FeedbinAccountDelegateError.unknown))
} else {
completion(.success(()))
}
}
}
func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) {
func refreshArticles(_ account: Account, page: String?, updateFetchDate: Date?, completion: @escaping ((Result<Void, Error>) -> Void)) {
guard let page = page else {
completion()
if let lastArticleFetch = updateFetchDate {
self.accountMetadata?.lastArticleFetch = lastArticleFetch
}
completion(.success(()))
return
}
@ -1072,20 +1147,19 @@ private extension FeedbinAccountDelegate {
self.processEntries(account: account, entries: entries) {
self.refreshProgress.completeTask()
self.refreshArticles(account, page: nextPage, completion: completion)
self.refreshArticles(account, page: nextPage, updateFetchDate: updateFetchDate, completion: completion)
}
case .failure(let error):
os_log(.error, log: self.log, "Refresh articles for additional pages failed: %@.", error.localizedDescription)
completion()
completion(.failure(error))
}
}
}
func processEntries(account: Account, entries: [FeedbinEntry]?, completion: @escaping (() -> Void)) {
let parsedItems = mapEntriesToParsedItems(entries: entries)
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion)
let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion)
}
func mapEntriesToParsedItems(entries: [FeedbinEntry]?) -> Set<ParsedItem> {
@ -1161,7 +1235,7 @@ private extension FeedbinAccountDelegate {
account.ensureStatuses(missingUnstarredArticleIDs, true, .starred, false)
}
func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
refreshProgress.addToNumberOfTasksAndRemaining(1)
@ -1171,7 +1245,7 @@ private extension FeedbinAccountDelegate {
case .success:
DispatchQueue.main.async {
self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
folder.removeFeed(feed)
folder.removeWebFeed(feed)
account.addFeedIfNotInAnyFolder(feed)
completion(.success(()))
}
@ -1184,14 +1258,14 @@ private extension FeedbinAccountDelegate {
}
} else {
if let account = container as? Account {
account.removeFeed(feed)
account.removeWebFeed(feed)
}
completion(.success(()))
}
}
func deleteSubscription(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
func deleteSubscription(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
@ -1205,11 +1279,11 @@ private extension FeedbinAccountDelegate {
switch result {
case .success:
DispatchQueue.main.async {
account.clearFeedMetadata(feed)
account.removeFeed(feed)
account.clearWebFeedMetadata(feed)
account.removeWebFeed(feed)
if let folders = account.folders {
for folder in folders {
folder.removeFeed(feed)
folder.removeWebFeed(feed)
}
}
completion(.success(()))
@ -1223,11 +1297,5 @@ private extension FeedbinAccountDelegate {
}
}
func retrieveCredentialsIfNecessary(_ account: Account) {
if credentials == nil {
credentials = try? account.retrieveCredentials(type: .basic)
}
}
}

View File

@ -1,21 +0,0 @@
//
// FeedbinIcon.swift
// Account
//
// Created by Maurice Parker on 5/6/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedbinIcon: Codable {
let host: String
let url: String
enum CodingKeys: String, CodingKey {
case host
case url
}
}

View File

@ -17,6 +17,7 @@ struct FeedbinSubscription: Hashable, Codable {
let name: String?
let url: String
let homePageURL: String?
let jsonFeed: FeedbinSubscriptionJSONFeed?
enum CodingKeys: String, CodingKey {
case subscriptionID = "id"
@ -24,11 +25,26 @@ struct FeedbinSubscription: Hashable, Codable {
case name = "title"
case url = "feed_url"
case homePageURL = "site_url"
case jsonFeed = "json_feed"
}
public func hash(into hasher: inout Hasher) {
hasher.combine(subscriptionID)
}
static func == (lhs: FeedbinSubscription, rhs: FeedbinSubscription) -> Bool {
return lhs.subscriptionID == rhs.subscriptionID
}
}
struct FeedbinSubscriptionJSONFeed: Codable {
let favicon: String?
let icon: String?
enum CodingKeys: String, CodingKey {
case favicon = "favicon"
case icon = "icon"
}
}
struct FeedbinCreateSubscription: Codable {

View File

@ -15,15 +15,6 @@ final class FeedlyAPICaller {
case sandbox
case cloud
static var `default`: API {
// https://developer.feedly.com/v3/developer/
if let token = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !token.isEmpty {
return .cloud
}
return .sandbox
}
var baseUrlComponents: URLComponents {
var components = URLComponents()
components.scheme = "https"
@ -37,6 +28,15 @@ final class FeedlyAPICaller {
}
return components
}
var oauthAuthorizationClient: OAuthAuthorizationClient {
switch self {
case .sandbox:
return .feedlySandboxClient
case .cloud:
return .feedlyCloudClient
}
}
}
private let transport: Transport
@ -53,169 +53,8 @@ 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 cancelAll() {
transport.cancelAll()
}
func importOpml(_ opmlData: Data, completionHandler: @escaping (Result<Void, Error>) -> ()) {
@ -482,13 +321,14 @@ final class FeedlyAPICaller {
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest) -> URLRequest {
let api = API.default
var components = api.baseUrlComponents
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest {
var components = baseUrlComponents
components.path = "/v3/auth/auth"
components.queryItems = request.queryItems
guard let url = components.url else {
assert(components.scheme != nil)
assert(components.host != nil)
fatalError("\(components) does not produce a valid URL.")
}
@ -538,3 +378,341 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
}
}
}
extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completionHandler: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
var components = baseUrlComponents
components.path = "/v3/auth/token"
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: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
do {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
request.httpBody = try encoder.encode(refreshRequest)
} catch {
DispatchQueue.main.async {
completionHandler(.failure(error))
}
return
}
transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, tokenResponse)):
if let response = tokenResponse {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
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))
}
}
}
}
extension FeedlyAPICaller: FeedlyLogoutService {
func logout(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/auth/logout"
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)
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

@ -27,19 +27,22 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions"
static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest {
static func oauthAuthorizationCodeGrantRequest() -> URLRequest {
let client = environment.oauthAuthorizationClient
let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id,
redirectUri: client.redirectUri,
scope: oauthAuthorizationGrantScope,
state: client.state)
return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest)
let baseURLComponents = environment.baseUrlComponents
return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest, baseUrlComponents: baseURLComponents)
}
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, client: OAuthAuthorizationClient, transport: Transport, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
let client = environment.oauthAuthorizationClient
let request = OAuthAccessTokenRequest(authorizationResponse: response,
scope: oauthAuthorizationGrantScope,
client: client)
let caller = FeedlyAPICaller(transport: transport, api: .default)
let caller = FeedlyAPICaller(transport: transport, api: environment)
caller.requestAccessToken(request) { result in
switch result {
case .success(let response):
@ -62,3 +65,30 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
}
}
}
extension FeedlyAccountDelegate: OAuthAccessTokenRefreshing {
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
let request = OAuthRefreshAccessTokenRequest(refreshToken: refreshToken, scope: nil, client: client)
caller.refreshAccessToken(request) { result in
switch result {
case .success(let response):
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
let refreshToken: Credentials? = {
guard let token = response.refreshToken else {
return nil
}
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
}()
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
completionHandler(.success(grant))
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}

View File

@ -14,6 +14,14 @@ import SyncDatabase
import os.log
final class FeedlyAccountDelegate: AccountDelegate {
/// Feedly has a sandbox API and a production API.
/// This property is referred to when clients need to know which environment it should be pointing to.
/// The value of this proptery must match any `OAuthAuthorizationClient` used.
/// Currently this is always returning the cloud API, but we are leaving it stubbed out for now.
static var environment: FeedlyAPICaller.API {
return .cloud
}
// TODO: Kiel, if you decide not to support OPML import you will have to disallow it in the behaviors
// See https://developer.feedly.com/v3/opml/
@ -38,18 +46,30 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
}
let oauthAuthorizationClient: OAuthAuthorizationClient
var accountMetadata: AccountMetadata?
var refreshProgress = DownloadProgress(numberOfTasks: 0)
private let caller: FeedlyAPICaller
internal let caller: FeedlyAPICaller
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
private let database: SyncDatabase
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
private weak var currentSyncAllOperation: FeedlySyncAllOperation?
private let operationQueue: OperationQueue
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API) {
self.operationQueue = OperationQueue()
// Many operations have their own operation queues, such as the sync all operation.
// Making this a serial queue at this higher level of abstraction means we can ensure,
// for example, a `FeedlyRefreshAccessTokenOperation` occurs before a `FeedlySyncAllOperation`,
// improving our ability to debug, reason about and predict the behaviour of the code.
self.operationQueue.maxConcurrentOperationCount = 1
if let transport = transport {
caller = FeedlyAPICaller(transport: transport, api: api)
self.caller = FeedlyAPICaller(transport: transport, api: api)
} else {
@ -67,38 +87,66 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
let session = URLSession(configuration: sessionConfiguration)
caller = FeedlyAPICaller(transport: session, api: api)
self.caller = FeedlyAPICaller(transport: session, api: api)
}
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
self.oauthAuthorizationClient = api.oauthAuthorizationClient
}
// MARK: Account API
private var syncStrategy: FeedlySyncStrategy?
func cancelAll(for account: Account) {
operationQueue.cancelAllOperations()
}
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
if case .success = result {
self?.accountMetadata?.lastArticleFetch = date
}
os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow)
progress.completeTask()
completion(result)
}
currentSyncAllOperation = operation
operationQueue.addOperation(operation)
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> 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()
completion(.success(()))
}
}
OperationQueue.main.addOperation(send)
operationQueue.addOperation(send)
}
/// Attempts to ensure local articles have the same status as they do remotely.
@ -111,10 +159,33 @@ 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
completion()
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
guard let credentials = credentials else {
return completion(.success(()))
}
let group = DispatchGroup()
let syncUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: caller, newerThan: nil, log: log)
group.enter()
syncUnread.completionBlock = {
group.leave()
}
let syncStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, service: caller, log: log)
group.enter()
syncStarred.completionBlock = {
group.leave()
}
group.notify(queue: .main) {
completion(.success(()))
}
operationQueue.addOperations([syncUnread, syncStarred], waitUntilFinished: false)
}
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
@ -223,7 +294,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
var createFeedRequest: FeedlyAddFeedRequest?
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
@ -239,14 +310,14 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
}
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
let folderCollectionIds = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID }
guard let collectionIds = folderCollectionIds, let collectionId = collectionIds.first else {
completion(.failure(FeedlyAccountDelegateError.unableToRenameFeed(feed.nameForDisplay, name)))
return
}
let feedId = FeedlyFeedResourceId(id: feed.feedID)
let feedId = FeedlyFeedResourceId(id: feed.webFeedID)
let editedNameBefore = feed.editedName
// Adding an existing feed updates it.
@ -268,7 +339,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
var addFeedRequest: FeedlyAddFeedRequest?
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
@ -291,62 +362,62 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
}
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
guard let folder = container as? Folder, let collectionId = folder.externalID else {
return DispatchQueue.main.async {
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFeed(feed)))
}
}
caller.removeFeed(feed.feedID, fromCollectionWith: collectionId) { result in
caller.removeFeed(feed.webFeedID, fromCollectionWith: collectionId) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
folder.addFeed(feed)
folder.addWebFeed(feed)
completion(.failure(error))
}
}
folder.removeFeed(feed)
folder.removeWebFeed(feed)
}
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
guard let from = from as? Folder, let to = to as? Folder else {
return DispatchQueue.main.async {
completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder))
}
}
addFeed(for: account, with: feed, to: to) { [weak self] addResult in
addWebFeed(for: account, with: feed, to: to) { [weak self] addResult in
switch addResult {
// now that we have added the feed, remove it from the other collection
case .success:
self?.removeFeed(for: account, with: feed, from: from) { removeResult in
self?.removeWebFeed(for: account, with: feed, from: from) { removeResult in
switch removeResult {
case .success:
completion(.success(()))
case .failure:
from.addFeed(feed)
from.addWebFeed(feed)
completion(.failure(FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed, from, to)))
}
}
case .failure(let error):
from.addFeed(feed)
to.removeFeed(feed)
from.addWebFeed(feed)
to.removeWebFeed(feed)
completion(.failure(error))
}
}
// optimistically move the feed, undoing as appropriate to the failure
from.removeFeed(feed)
to.addFeed(feed)
from.removeWebFeed(feed)
to.addWebFeed(feed)
}
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if let existingFeed = account.existingFeed(withURL: feed.url) {
account.addFeed(existingFeed, to: container) { result in
func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if let existingFeed = account.existingWebFeed(withURL: feed.url) {
account.addWebFeed(existingFeed, to: container) { result in
switch result {
case .success:
completion(.success(()))
@ -355,7 +426,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
}
} else {
createFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in
createWebFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in
switch result {
case .success:
completion(.success(()))
@ -369,12 +440,12 @@ final class FeedlyAccountDelegate: AccountDelegate {
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
let group = DispatchGroup()
for feed in folder.topLevelFeeds {
for feed in folder.topLevelWebFeeds {
folder.topLevelFeeds.remove(feed)
folder.topLevelWebFeeds.remove(feed)
group.enter()
restoreFeed(for: account, feed: feed, container: folder) { result in
restoreWebFeed(for: account, feed: feed, container: folder) { result in
group.leave()
switch result {
case .success:
@ -402,7 +473,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
os_log(.debug, log: log, "Marking %@ as %@.", articles.map { $0.title }, syncStatuses)
if database.selectPendingCount() > 100 {
sendArticleStatus(for: account) { }
sendArticleStatus(for: account) { _ in }
}
return account.update(articles, statusKey: statusKey, flag: flag)
@ -411,13 +482,18 @@ final class FeedlyAccountDelegate: AccountDelegate {
func accountDidInitialize(_ account: Account) {
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
syncStrategy = FeedlySyncStrategy(account: account,
caller: caller,
database: database,
log: log)
let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log)
operationQueue.addOperation(refreshAccessToken)
}
func accountWillBeDeleted(_ account: Account) {
let logout = FeedlyLogoutOperation(account: account, service: caller, log: log)
// Dispatch on the main queue because the lifetime of the account delegate is uncertain.
OperationQueue.main.addOperation(logout)
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {
fatalError()
assertionFailure("An `account` instance should enqueue an \(FeedlyRefreshAccessTokenOperation.self) instead.")
completion(.success(credentials))
}
}

View File

@ -13,11 +13,11 @@ enum FeedlyAccountDelegateError: LocalizedError {
case unableToAddFolder(String)
case unableToRenameFolder(String, String)
case unableToRemoveFolder(String)
case unableToMoveFeedBetweenFolders(Feed, Folder, Folder)
case unableToMoveFeedBetweenFolders(WebFeed, Folder, Folder)
case addFeedChooseFolder
case addFeedInvalidFolder(Folder)
case unableToRenameFeed(String, String)
case unableToRemoveFeed(Feed)
case unableToRemoveFeed(WebFeed)
var errorDescription: String? {
switch self {

View File

@ -29,7 +29,7 @@ final class FeedlyAddFeedRequest {
self.resourceProvider = resourceProvider
}
var completionHandler: ((Result<Feed, Error>) -> ())?
var completionHandler: ((Result<WebFeed, Error>) -> ())?
var error: Error?
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
@ -37,17 +37,17 @@ final class FeedlyAddFeedRequest {
}
}
func addNewFeed(at url: String, name: String? = nil, completion: @escaping (Result<Feed, Error>) -> Void) {
func addNewFeed(at url: String, name: String? = nil, completion: @escaping (Result<WebFeed, Error>) -> Void) {
let resource = FeedlyFeedResourceId(url: url)
self.start(resource: resource, name: name, refreshes: true, completion: completion)
}
func add(existing feed: Feed, name: String? = nil, completion: @escaping (Result<Feed, Error>) -> Void) {
let resource = FeedlyFeedResourceId(id: feed.feedID)
func add(existing feed: WebFeed, name: String? = nil, completion: @escaping (Result<WebFeed, Error>) -> Void) {
let resource = FeedlyFeedResourceId(id: feed.webFeedID)
self.start(resource: resource, name: name, refreshes: false, completion: completion)
}
private func start(resource: FeedlyFeedResourceId, name: String?, refreshes: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
private func start(resource: FeedlyFeedResourceId, name: String?, refreshes: Bool, completion: @escaping (Result<WebFeed, Error>) -> Void) {
let (folder, collectionId): (Folder, String)
do {
@ -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
}
@ -115,7 +115,7 @@ final class FeedlyAddFeedRequest {
if let error = delegate.error {
handler(.failure(error))
} else if let feed = folder.existingFeed(withFeedID: resource.id) {
} else if let feed = folder.existingWebFeed(withWebFeedID: resource.id) {
handler(.success(feed))
} else {

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]

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