diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 4eac70446..5bbc1f6b2 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -31,10 +31,9 @@ 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 WebFeedMetadataDidChange = Notification.Name(rawValue: "WebFeedMetadataDidChange") } -public enum AccountType: Int { +public enum AccountType: Int, Codable { // Raw values should not change since they’re stored on disk. case onMyMac = 1 case feedly = 16 @@ -199,8 +198,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, typealias WebFeedMetadataDictionary = [String: WebFeedMetadata] var webFeedMetadata = WebFeedMetadataDictionary() - var startingUp = true - public var unreadCount = 0 { didSet { if unreadCount != oldValue { @@ -230,7 +227,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, var refreshProgress: DownloadProgress { return delegate.refreshProgress } - + init?(dataFolder: String, type: AccountType, accountID: String, transport: Transport? = nil) { switch type { case .onMyMac: @@ -287,7 +284,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } self.delegate.accountDidInitialize(self) - startingUp = false } // MARK: - API @@ -414,11 +410,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func suspendDatabase() { - database.suspend() + database.cancelAndSuspend() save() - metadataFile.suspend() - webFeedMetadataFile.suspend() - opmlFile.suspend() } /// Re-open the SQLite database and allow database calls. @@ -430,12 +423,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, /// Reload OPML, etc. public func resume() { - metadataFile.resume() - webFeedMetadataFile.resume() - opmlFile.resume() - metadataFile.load() - webFeedMetadataFile.load() - opmlFile.load() + fetchAllUnreadCounts() } public func save() { @@ -447,7 +435,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public func prepareForDeletion() { delegate.accountWillBeDeleted(self) } - + func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) { var feedsToAdd = Set() @@ -487,14 +475,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } - public func resetWebFeedMetadataAndUnreadCounts() { - for feed in flattenedWebFeeds() { - feed.metadata = webFeedMetadata(feedURL: feed.url, webFeedID: feed.webFeedID) - } - fetchAllUnreadCounts() - NotificationCenter.default.post(name: .WebFeedMetadataDidChange, object: self, userInfo: nil) - } - public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag) } @@ -605,22 +585,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func updateUnreadCounts(for webFeeds: Set, completion: VoidCompletionBlock? = nil) { - if webFeeds.isEmpty { - completion?() - return - } - - database.fetchUnreadCounts(for: webFeeds.webFeedIDs()) { unreadCountDictionaryResult in - if let unreadCountDictionary = try? unreadCountDictionaryResult.get() { - for webFeed in webFeeds { - if let unreadCount = unreadCountDictionary[webFeed.webFeedID] { - webFeed.unreadCount = unreadCount - } - } - } - - completion?() - } + fetchUnreadCounts(for: webFeeds, completion: completion) } public func fetchArticles(_ fetchType: FetchType) throws -> Set
{ @@ -689,7 +654,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, database.fetchStarredArticleIDsAsync(webFeedIDs: flattenedWebFeeds().webFeedIDs(), completion: completion) } - /// Fetch articleIDs for articles that we should have, but don’t. These articles are not userDeleted, and they are either (starred) or (unread and newer than the article cutoff date). + /// Fetch articleIDs for articles that we should have, but don’t. These articles are not userDeleted, and they are either (starred) or (newer than the article cutoff date). public func fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(_ completion: @escaping ArticleIDsCompletionBlock) { database.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(completion) } @@ -705,9 +670,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public func structureDidChange() { // Feeds were added or deleted. Or folders added or deleted. // Or feeds inside folders were added or deleted. - if !startingUp { - opmlFile.markAsDirty() - } + opmlFile.markAsDirty() flattenedWebFeedsNeedUpdate = true webFeedDictionaryNeedsUpdate = true } @@ -791,6 +754,24 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return updatedArticles } + /// Make sure statuses exist. Any existing statuses won’t be touched. + /// All created statuses will be marked as read and not starred. + /// Sends a .StatusesDidChange notification. + func createStatusesIfNeeded(articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { + guard !articleIDs.isEmpty else { + completion?(nil) + return + } + database.createStatusesIfNeeded(articleIDs: articleIDs) { error in + if let error = error { + completion?(error) + return + } + self.noteStatusesForArticleIDsDidChange(articleIDs) + completion?(nil) + } + } + /// Mark articleIDs statuses based on statusKey and flag. /// Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. func mark(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) { @@ -1206,26 +1187,69 @@ private extension Account { NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.articleIDs: articleIDs]) } - func fetchAllUnreadCounts() { + /// Fetch unread counts for zero or more feeds. + /// + /// Uses the most efficient method based on how many feeds were passed in. + func fetchUnreadCounts(for feeds: Set, completion: VoidCompletionBlock?) { + if feeds.isEmpty { + completion?() + return + } + if feeds.count == 1, let feed = feeds.first { + fetchUnreadCount(feed, completion) + } + else if feeds.count < 10 { + fetchUnreadCounts(feeds, completion) + } + else { + fetchAllUnreadCounts(completion) + } + } + + func fetchUnreadCount(_ feed: WebFeed, _ completion: VoidCompletionBlock?) { + database.fetchUnreadCount(feed.webFeedID) { result in + if let unreadCount = try? result.get() { + feed.unreadCount = unreadCount + } + completion?() + } + } + + func fetchUnreadCounts(_ feeds: Set, _ completion: VoidCompletionBlock?) { + let webFeedIDs = Set(feeds.map { $0.webFeedID }) + database.fetchUnreadCounts(for: webFeedIDs) { result in + if let unreadCountDictionary = try? result.get() { + self.processUnreadCounts(unreadCountDictionary: unreadCountDictionary, feeds: feeds) + } + completion?() + } + } + + func fetchAllUnreadCounts(_ completion: VoidCompletionBlock? = nil) { fetchingAllUnreadCounts = true + database.fetchAllUnreadCounts { result in + guard let unreadCountDictionary = try? result.get() else { + completion?() + return + } + self.processUnreadCounts(unreadCountDictionary: unreadCountDictionary, feeds: self.flattenedWebFeeds()) - database.fetchAllNonZeroUnreadCounts { (unreadCountDictionaryResult) in - if let unreadCountDictionary = try? unreadCountDictionaryResult.get() { - self.flattenedWebFeeds().forEach{ (feed) in - // When the unread count is zero, it won’t appear in unreadCountDictionary. - if let unreadCount = unreadCountDictionary[feed.webFeedID] { - feed.unreadCount = unreadCount - } - else { - feed.unreadCount = 0 - } - } + self.fetchingAllUnreadCounts = false + self.updateUnreadCount() - self.fetchingAllUnreadCounts = false - self.updateUnreadCount() + if !self.isUnreadCountsInitialized { self.isUnreadCountsInitialized = true self.postUnreadCountDidInitializeNotification() } + completion?() + } + } + + func processUnreadCounts(unreadCountDictionary: UnreadCountDictionary, feeds: Set) { + for feed in feeds { + // When the unread count is zero, it won’t appear in unreadCountDictionary. + let unreadCount = unreadCountDictionary[feed.webFeedID] ?? 0 + feed.unreadCount = unreadCount } } } @@ -1243,13 +1267,13 @@ extension Account { extension Account: OPMLRepresentable { - public func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { + public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { var s = "" - for feed in topLevelWebFeeds.sorted(by: { $0.nameForDisplay < $1.nameForDisplay }) { - s += feed.OPMLString(indentLevel: indentLevel + 1, strictConformance: strictConformance) + for feed in topLevelWebFeeds.sorted() { + s += feed.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes) } - for folder in folders!.sorted(by: { $0.nameForDisplay < $1.nameForDisplay }) { - s += folder.OPMLString(indentLevel: indentLevel + 1, strictConformance: strictConformance) + for folder in folders!.sorted() { + s += folder.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes) } return s } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index bd6997e20..62d8daf90 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; }; - 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; @@ -88,10 +87,8 @@ 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 */; }; @@ -99,7 +96,6 @@ 9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */; }; 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 /* 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 */; }; @@ -110,28 +106,22 @@ 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 */; }; + 9E44C90F23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E44C90E23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift */; }; 9E5ABE9A236BE6BD00B5DE9F /* feedly-1-initial in Resources */ = {isa = PBXBuildFile; fileRef = 9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */; }; + 9E5DE60E23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5DE60D23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift */; }; + 9E5EC15923E01D8A00A4E503 /* FeedlyCollectionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5EC15823E01D8A00A4E503 /* FeedlyCollectionParser.swift */; }; + 9E5EC15B23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5EC15A23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift */; }; + 9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5EC15C23E0D58500A4E503 /* FeedlyFeedParser.swift */; }; 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 /* FeedlyAddFeedToCollectionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D623505E9600DAEFB7 /* FeedlyAddFeedToCollectionOperation.swift */; }; 9E7299D9235062A200DAEFB7 /* FeedlyResourceProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */; }; 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */; }; - 9E784EC0237E8BE100099B1B /* FeedlyLogoutOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E784EBF237E8BE100099B1B /* FeedlyLogoutOperationTests.swift */; }; 9E79F7742395C9F00031DB98 /* feedly-add-new-feed in Resources */ = {isa = PBXBuildFile; fileRef = 9E79F7732395C9EF0031DB98 /* feedly-add-new-feed */; }; - 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 */; }; + 9E84DC472359A23200D6E809 /* FeedlyIngestUnreadArticleIdsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E84DC462359A23200D6E809 /* FeedlyIngestUnreadArticleIdsOperation.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 */; }; @@ -142,7 +132,7 @@ 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA643CE2391D3550018A28C /* FeedlyAddExistingFeedOperation.swift */; }; 9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA643D2239305680018A28C /* FeedlySearchOperation.swift */; }; 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA643D4239306AC0018A28C /* FeedlyFeedsSearchResponse.swift */; }; - 9EA643D923945CE00018A28C /* FeedlyAddNewFeedOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA643D823945CE00018A28C /* FeedlyAddNewFeedOperationTests.swift */; }; + 9EAADA1023C93144003A801F /* TestGetEntriesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAADA0F23C93144003A801F /* TestGetEntriesService.swift */; }; 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */; }; 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */; }; 9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */; }; @@ -150,10 +140,9 @@ 9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */; }; 9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */; }; 9EB1D576238E6A3900A753D7 /* FeedlyAddNewFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB1D575238E6A3900A753D7 /* FeedlyAddNewFeedOperation.swift */; }; - 9EC228552362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC228542362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift */; }; + 9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBD49BF23C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift */; }; + 9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBD49C123C67784005AD5CD /* FeedlyEntryIdentifierProviding.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 */; }; @@ -173,11 +162,15 @@ 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 */; }; + 9EEEF7212355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEEF7202355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift */; }; 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 */; }; + 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF2602B23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift */; }; + 9EF58EB023E1606000992A2B /* FeedlyTextSanitizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF58EAF23E1606000992A2B /* FeedlyTextSanitizationTests.swift */; }; + 9EF58EB223E1647400992A2B /* FeedlyCollectionParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF58EB123E1647400992A2B /* FeedlyCollectionParserTests.swift */; }; + 9EF58EB423E1655300992A2B /* FeedlyFeedParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF58EB323E1655300992A2B /* FeedlyFeedParserTests.swift */; }; + 9EF58EB623E1669F00992A2B /* FeedlyEntryParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF58EB523E1669F00992A2B /* FeedlyEntryParserTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -322,7 +315,6 @@ 9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyTag.swift; sourceTree = ""; }; 9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceId.swift; sourceTree = ""; }; 9E1773DA234593CF0056A5A8 /* FeedlyResourceIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceIdTests.swift; sourceTree = ""; }; - 9E1AF38A2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetStarredArticlesOperation.swift; sourceTree = ""; }; 9E1D154C233370D800F4944C /* FeedlySyncAllOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncAllOperation.swift; sourceTree = ""; }; 9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsOperation.swift; sourceTree = ""; }; 9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMirrorCollectionsAsFoldersOperation.swift; sourceTree = ""; }; @@ -336,15 +328,17 @@ 9E1FF8632368EC2400834C24 /* FeedlySyncAllOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncAllOperationTests.swift; sourceTree = ""; }; 9E1FF8652368ED7E00834C24 /* TestMarkArticlesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMarkArticlesService.swift; sourceTree = ""; }; 9E1FF8672368EE4900834C24 /* TestGetCollectionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetCollectionsService.swift; sourceTree = ""; }; - 9E3CFFFC2368202000BA7365 /* FeedlySyncUnreadStatusesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncUnreadStatusesOperationTests.swift; sourceTree = ""; }; - 9E4828F123617F4A00D68691 /* FeedlySetUnreadArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetUnreadArticlesOperationTests.swift; sourceTree = ""; }; + 9E44C90E23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyIngestStreamArticleIdsOperation.swift; sourceTree = ""; }; 9E489E8C2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsOperationTests.swift; sourceTree = ""; }; 9E489E902360ED30004372EE /* FeedlyOrganiseParsedItemsByFeedOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrganiseParsedItemsByFeedOperationTests.swift; sourceTree = ""; }; 9E489E92236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyUpdateAccountFeedsWithItemsOperationTests.swift; sourceTree = ""; }; 9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-1-initial"; sourceTree = ""; }; + 9E5DE60D23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFetchIdsForMissingArticlesOperation.swift; sourceTree = ""; }; + 9E5EC15823E01D8A00A4E503 /* FeedlyCollectionParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollectionParser.swift; sourceTree = ""; }; + 9E5EC15A23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRTLTextSanitizer.swift; sourceTree = ""; }; + 9E5EC15C23E0D58500A4E503 /* FeedlyFeedParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeedParser.swift; sourceTree = ""; }; 9E672393236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshAccessTokenOperation.swift; sourceTree = ""; }; 9E672395236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAcessTokenRefreshing.swift; sourceTree = ""; }; - 9E713652233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetUnreadArticlesOperation.swift; sourceTree = ""; }; 9E7299D623505E9600DAEFB7 /* FeedlyAddFeedToCollectionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddFeedToCollectionOperation.swift; sourceTree = ""; }; 9E7299D8235062A200DAEFB7 /* FeedlyResourceProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyResourceProviding.swift; sourceTree = ""; }; 9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyLogoutOperation.swift; sourceTree = ""; }; @@ -352,9 +346,8 @@ 9E79F7732395C9EF0031DB98 /* feedly-add-new-feed */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "feedly-add-new-feed"; sourceTree = ""; }; 9E7F88AB235EDDC2009AB9DF /* FeedlyCreateFeedsForCollectionFoldersOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCreateFeedsForCollectionFoldersOperationTests.swift; sourceTree = ""; }; 9E7F88AD235FBB11009AB9DF /* FeedlyGetStreamContentsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamContentsOperationTests.swift; sourceTree = ""; }; - 9E84DC462359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncUnreadStatusesOperation.swift; sourceTree = ""; }; + 9E84DC462359A23200D6E809 /* FeedlyIngestUnreadArticleIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyIngestUnreadArticleIdsOperation.swift; sourceTree = ""; }; 9E84DC482359A73600D6E809 /* FeedlyCheckpointOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCheckpointOperation.swift; sourceTree = ""; }; - 9E85C8E32366FE0100D0F1F7 /* FeedlySyncStarredArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStarredArticlesOperationTests.swift; sourceTree = ""; }; 9E85C8E52366FED600D0F1F7 /* TestGetStreamContentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetStreamContentsService.swift; sourceTree = ""; }; 9E85C8E72366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetPagedStreamContentsService.swift; sourceTree = ""; }; 9E85C8E9236700AD00D0F1F7 /* FeedlyGetEntriesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetEntriesOperation.swift; sourceTree = ""; }; @@ -366,6 +359,7 @@ 9EA643D2239305680018A28C /* FeedlySearchOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySearchOperation.swift; sourceTree = ""; }; 9EA643D4239306AC0018A28C /* FeedlyFeedsSearchResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeedsSearchResponse.swift; sourceTree = ""; }; 9EA643D823945CE00018A28C /* FeedlyAddNewFeedOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddNewFeedOperationTests.swift; sourceTree = ""; }; + 9EAADA0F23C93144003A801F /* TestGetEntriesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestGetEntriesService.swift; sourceTree = ""; }; 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollection.swift; sourceTree = ""; }; 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeed.swift; sourceTree = ""; }; 9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntry.swift; sourceTree = ""; }; @@ -373,7 +367,8 @@ 9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCategory.swift; sourceTree = ""; }; 9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrigin.swift; sourceTree = ""; }; 9EB1D575238E6A3900A753D7 /* FeedlyAddNewFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAddNewFeedOperation.swift; sourceTree = ""; }; - 9EC228542362C17F00766EF8 /* FeedlySetStarredArticlesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySetStarredArticlesOperationTests.swift; sourceTree = ""; }; + 9EBD49BF23C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyDownloadArticlesOperation.swift; sourceTree = ""; }; + 9EBD49C123C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntryIdentifierProviding.swift; sourceTree = ""; }; 9EC228562362C7F900766EF8 /* FeedlyCheckpointOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCheckpointOperationTests.swift; sourceTree = ""; }; 9EC228582362D0EA00766EF8 /* FeedlySendArticleStatusesOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySendArticleStatusesOperationTests.swift; sourceTree = ""; }; 9EC2285A23639A6500766EF8 /* FeedlySyncStreamContentsOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStreamContentsOperationTests.swift; sourceTree = ""; }; @@ -396,11 +391,15 @@ 9EEAE072235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsService.swift; sourceTree = ""; }; 9EEAE074235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMarkArticlesService.swift; sourceTree = ""; }; 9EEEF71E23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySendArticleStatusesOperation.swift; sourceTree = ""; }; - 9EEEF7202355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStarredArticlesOperation.swift; sourceTree = ""; }; + 9EEEF7202355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyIngestStarredArticleIdsOperation.swift; sourceTree = ""; }; 9EF1B10223584B4C000A486A /* FeedlySyncStreamContentsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStreamContentsOperation.swift; sourceTree = ""; }; 9EF1B10623590D61000A486A /* FeedlyGetStreamIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamIdsOperation.swift; sourceTree = ""; }; 9EF1B10823590E93000A486A /* FeedlyStreamIds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyStreamIds.swift; sourceTree = ""; }; - 9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCompoundOperation.swift; sourceTree = ""; }; + 9EF2602B23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetUpdatedArticleIdsOperation.swift; sourceTree = ""; }; + 9EF58EAF23E1606000992A2B /* FeedlyTextSanitizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyTextSanitizationTests.swift; sourceTree = ""; }; + 9EF58EB123E1647400992A2B /* FeedlyCollectionParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollectionParserTests.swift; sourceTree = ""; }; + 9EF58EB323E1655300992A2B /* FeedlyFeedParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeedParserTests.swift; sourceTree = ""; }; + 9EF58EB523E1669F00992A2B /* FeedlyEntryParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntryParserTests.swift; sourceTree = ""; }; D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = ""; }; D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = ""; }; D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = ""; }; @@ -648,6 +647,7 @@ 9E85C8E72366FF4200D0F1F7 /* TestGetPagedStreamContentsService.swift */, 9E1FF8612368219B00834C24 /* TestGetPagedStreamIdsService.swift */, 9E1FF8672368EE4900834C24 /* TestGetCollectionsService.swift */, + 9EAADA0F23C93144003A801F /* TestGetEntriesService.swift */, 9E1FF8652368ED7E00834C24 /* TestMarkArticlesService.swift */, 9E03C11B235D921400FB6D9E /* FeedlyOperationTests.swift */, 9E03C11D235D976500FB6D9E /* FeedlyGetCollectionsOperationTests.swift */, @@ -657,19 +657,19 @@ 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 */, 9E0260CA236FF99A00D122D3 /* FeedlyRefreshAccessTokenOperationTests.swift */, 9E784EBF237E8BE100099B1B /* FeedlyLogoutOperationTests.swift */, 9EA643D823945CE00018A28C /* FeedlyAddNewFeedOperationTests.swift */, + 9EF58EAF23E1606000992A2B /* FeedlyTextSanitizationTests.swift */, + 9EF58EB123E1647400992A2B /* FeedlyCollectionParserTests.swift */, + 9EF58EB323E1655300992A2B /* FeedlyFeedParserTests.swift */, + 9EF58EB523E1669F00992A2B /* FeedlyEntryParserTests.swift */, 9E79F7732395C9EF0031DB98 /* feedly-add-new-feed */, 9E5ABE99236BE6BC00B5DE9F /* feedly-1-initial */, 9EC804E4236C1A7F0057CFCB /* feedly-2-changestatuses */, @@ -704,7 +704,7 @@ isa = PBXGroup; children = ( 9E1D1554233431A600F4944C /* FeedlyOperation.swift */, - 9EF35F79234E830E003AE2AE /* FeedlyCompoundOperation.swift */, + 9E1D154C233370D800F4944C /* FeedlySyncAllOperation.swift */, 9EA643D2239305680018A28C /* FeedlySearchOperation.swift */, 9E7299D623505E9600DAEFB7 /* FeedlyAddFeedToCollectionOperation.swift */, 9EB1D575238E6A3900A753D7 /* FeedlyAddNewFeedOperation.swift */, @@ -718,16 +718,17 @@ 9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */, 9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.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 */, + 9E44C90E23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift */, + 9EEEF7202355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift */, + 9E84DC462359A23200D6E809 /* FeedlyIngestUnreadArticleIdsOperation.swift */, + 9EF2602B23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift */, 9E672393236F7CA0000BE141 /* FeedlyRefreshAccessTokenOperation.swift */, 9E784EBD237E890600099B1B /* FeedlyLogoutOperation.swift */, + 9E5DE60D23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift */, + 9EBD49BF23C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift */, ); path = Operations; sourceTree = ""; @@ -737,7 +738,9 @@ children = ( 9E1773D823458D590056A5A8 /* FeedlyResourceId.swift */, 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */, + 9E5EC15823E01D8A00A4E503 /* FeedlyCollectionParser.swift */, 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */, + 9E5EC15C23E0D58500A4E503 /* FeedlyFeedParser.swift */, 9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */, 9E1773D4234570E30056A5A8 /* FeedlyEntryParser.swift */, 9EAEC625233318400085D7C9 /* FeedlyStream.swift */, @@ -747,6 +750,8 @@ 9E1773D22345700E0056A5A8 /* FeedlyLink.swift */, 9E1773D6234575AB0056A5A8 /* FeedlyTag.swift */, 9EA643D4239306AC0018A28C /* FeedlyFeedsSearchResponse.swift */, + 9EBD49C123C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift */, + 9E5EC15A23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift */, ); path = Models; sourceTree = ""; @@ -839,11 +844,11 @@ 848934F51F62484F00CEBD24 = { CreatedOnToolsVersion = 9.0; LastSwiftMigration = 0900; - ProvisioningStyle = Automatic; + ProvisioningStyle = Manual; }; 848934FE1F62484F00CEBD24 = { CreatedOnToolsVersion = 9.0; - ProvisioningStyle = Automatic; + ProvisioningStyle = Manual; }; }; }; @@ -975,7 +980,6 @@ buildActionMask = 2147483647; files = ( 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, - 9EF35F7A234E830E003AE2AE /* FeedlyCompoundOperation.swift in Sources */, 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */, @@ -994,27 +998,31 @@ 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */, + 9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */, 9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */, + 9E5EC15B23E01DEF00A4E503 /* FeedlyRTLTextSanitizer.swift in Sources */, 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */, 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, - 9E713653233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, + 9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, 515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */, 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, 9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */, + 9E5EC15923E01D8A00A4E503 /* FeedlyCollectionParser.swift in Sources */, 9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */, 9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */, + 9E44C90F23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift in Sources */, 844B297D2106C7EC004020B3 /* WebFeed.swift in Sources */, 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */, 9E964EBA23754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift in Sources */, @@ -1031,10 +1039,10 @@ 9EF1B10323584B4C000A486A /* FeedlySyncStreamContentsOperation.swift in Sources */, 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */, 84B2D4D02238CD8A00498ADA /* WebFeedMetadata.swift in Sources */, - 9E84DC472359A23200D6E809 /* FeedlySyncUnreadStatusesOperation.swift in Sources */, + 9E84DC472359A23200D6E809 /* FeedlyIngestUnreadArticleIdsOperation.swift in Sources */, 9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */, 9EEAE073235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift in Sources */, - 9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */, + 9EEEF7212355277F009E9D80 /* FeedlyIngestStarredArticleIdsOperation.swift in Sources */, 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */, 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, @@ -1044,6 +1052,7 @@ 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */, + 9E5DE60E23C3F4B70064DA30 /* FeedlyFetchIdsForMissingArticlesOperation.swift in Sources */, 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */, 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, @@ -1052,6 +1061,7 @@ 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */, 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, + 9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, 9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */, 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */, @@ -1079,10 +1089,10 @@ 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, - 9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, + 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, ); @@ -1094,38 +1104,26 @@ files = ( 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 */, + 9EAADA1023C93144003A801F /* TestGetEntriesService.swift in Sources */, + 9EF58EB423E1655300992A2B /* FeedlyFeedParserTests.swift in Sources */, 9E1FF8622368219B00834C24 /* TestGetPagedStreamIdsService.swift in Sources */, - 9EA643D923945CE00018A28C /* FeedlyAddNewFeedOperationTests.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 */, + 9EF58EB023E1606000992A2B /* FeedlyTextSanitizationTests.swift in Sources */, 5165D7122282080C00D9D53D /* AccountFeedbinFolderContentsSyncTest.swift in Sources */, - 9E489E93236101FC004372EE /* FeedlyUpdateAccountFeedsWithItemsOperationTests.swift in Sources */, - 9E489E8D2360652C004372EE /* FeedlyGetStreamIdsOperationTests.swift in Sources */, 51D5875E227F643C00900287 /* AccountFeedbinFolderSyncTest.swift in Sources */, + 9EF58EB623E1669F00992A2B /* FeedlyEntryParserTests.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 */, + 9EF58EB223E1647400992A2B /* FeedlyCollectionParserTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/AccountBehaviors.swift b/Frameworks/Account/AccountBehaviors.swift index 058e94b5d..83b8aae9b 100644 --- a/Frameworks/Account/AccountBehaviors.swift +++ b/Frameworks/Account/AccountBehaviors.swift @@ -14,25 +14,28 @@ import Foundation user interface as much as possible. For example some sync services don't allow feeds to be in the root folder of the account. */ -public struct AccountBehaviors: OptionSet { +public typealias AccountBehaviors = [AccountBehavior] + +public enum AccountBehavior: Equatable { /** Account doesn't support copies of a feed that are in a folder to be made to the root folder. */ - public static let disallowFeedCopyInRootFolder = AccountBehaviors(rawValue: 1) + case disallowFeedCopyInRootFolder /** Account doesn't support feeds in the root folder. */ - public static let disallowFeedInRootFolder = AccountBehaviors(rawValue: 2) + case disallowFeedInRootFolder /** Account doesn't support OPML imports */ - public static let disallowOPMLImports = AccountBehaviors(rawValue: 3) + case disallowOPMLImports - public let rawValue: Int - public init(rawValue: Int) { - self.rawValue = rawValue - } + /** + Account doesn't allow mark as read after a period of days + */ + case disallowMarkAsUnreadAfterPeriod(Int) + } diff --git a/Frameworks/Account/AccountError.swift b/Frameworks/Account/AccountError.swift index 2c7fdb720..734b957a9 100644 --- a/Frameworks/Account/AccountError.swift +++ b/Frameworks/Account/AccountError.swift @@ -16,6 +16,23 @@ public enum AccountError: LocalizedError { case opmlImportInProgress case wrappedError(error: Error, account: Account) + public var acount: Account? { + if case .wrappedError(_, let account) = self { + return account + } else { + return nil + } + } + + public var isCredentialsError: Bool { + if case .wrappedError(let error, _) = self { + if case TransportError.httpError(let status) = error { + return isCredentialsError(status: status) + } + } + return false + } + public var errorDescription: String? { switch self { case .createErrorNotFound: @@ -27,7 +44,7 @@ public enum AccountError: LocalizedError { case .wrappedError(let error, let account): switch error { case TransportError.httpError(let status): - if status == 401 { + if isCredentialsError(status: status) { let localizedText = NSLocalizedString("Your “%@” credentials are invalid or expired.", comment: "Invalid or expired") return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay) as String } else { @@ -48,7 +65,7 @@ public enum AccountError: LocalizedError { case .wrappedError(let error, _): switch error { case TransportError.httpError(let status): - if status == 401 || status == 403 { + if isCredentialsError(status: status) { return NSLocalizedString("Please update your credentials for this account, or ensure that your account with this service is still valid.", comment: "Expired credentials") } else { return NSLocalizedString("Please try again later.", comment: "Try later") @@ -61,8 +78,19 @@ public enum AccountError: LocalizedError { } } - private func unknownError(_ error: Error, _ account: Account) -> String { +} + +// MARK: Private + +private extension AccountError { + + func unknownError(_ error: Error, _ account: Account) -> String { let localizedText = NSLocalizedString("An error occurred while processing the “%@” account: %@", comment: "Unknown error") return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String } + + func isCredentialsError(status: Int) -> Bool { + return status == 401 || status == 403 + } + } diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 8183e68d3..fd357b8cc 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -90,14 +90,6 @@ public final class AccountManager: UnreadCountProvider { return CombinedRefreshProgress(downloadProgressArray: downloadProgressArray) } - public convenience init() { - let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String - let accountsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) - let accountsFolder = accountsURL!.appendingPathComponent("Accounts").absoluteString - let accountsFolderPath = accountsFolder.suffix(from: accountsFolder.index(accountsFolder.startIndex, offsetBy: 7)) - self.init(accountsFolder: String(accountsFolderPath)) - } - public init(accountsFolder: String) { self.accountsFolder = accountsFolder @@ -272,6 +264,11 @@ public final class AccountManager: UnreadCountProvider { var allFetchedArticles = Set
() let numberOfAccounts = activeAccounts.count var accountsReporting = 0 + + guard numberOfAccounts > 0 else { + completion(.success(allFetchedArticles)) + return + } for account in activeAccounts { account.fetchArticlesAsync(fetchType) { (articleSetResult) in @@ -389,7 +386,7 @@ private struct AccountSpecifier { init?(folderPath: String) { - if !FileManager.default.rs_fileIsFolder(folderPath) { + if !FileManager.default.isFolder(atPath: folderPath) { return nil } diff --git a/Frameworks/Account/AccountMetadataFile.swift b/Frameworks/Account/AccountMetadataFile.swift index 0ec91eee8..6e17ac734 100644 --- a/Frameworks/Account/AccountMetadataFile.swift +++ b/Frameworks/Account/AccountMetadataFile.swift @@ -16,41 +16,26 @@ final class AccountMetadataFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension AccountMetadataFile { - - func loadCallback() { - let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in if let fileData = try? Data(contentsOf: readURL) { @@ -63,17 +48,16 @@ private extension AccountMetadataFile { if let error = errorPointer?.pointee { os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription) } - } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let encoder = PropertyListEncoder() encoder.outputFormat = .binary let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -90,3 +74,18 @@ private extension AccountMetadataFile { } } + +private extension AccountMetadataFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + +} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyAddNewFeedOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyAddNewFeedOperationTests.swift index c218e8f07..b1b5e876e 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyAddNewFeedOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyAddNewFeedOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSWeb +import RSCore class FeedlyAddNewFeedOperationTests: XCTestCase { @@ -42,17 +43,17 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { let getCollections = FeedlyGetCollectionsOperation(service: caller, log: support.log) let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: support.log) - mirrorCollectionsAsFolders.addDependency(getCollections) - + MainThreadOperationQueue.shared.make(mirrorCollectionsAsFolders, dependOn: getCollections) + let createFolders = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: mirrorCollectionsAsFolders, log: support.log) - createFolders.addDependency(mirrorCollectionsAsFolders) + MainThreadOperationQueue.shared.make(createFolders, dependOn: mirrorCollectionsAsFolders) let completionExpectation = expectation(description: "Did Finish") - createFolders.completionBlock = { + createFolders.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperations([getCollections, mirrorCollectionsAsFolders, createFolders], waitUntilFinished: false) + MainThreadOperationQueue.shared.addOperations([getCollections, mirrorCollectionsAsFolders, createFolders]) waitForExpectations(timeout: 2) @@ -89,6 +90,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { } let progress = DownloadProgress(numberOfTasks: 0) + let container = support.makeTestDatabaseContainer() let _ = expectationForCompletion(of: progress) let addNewFeed = try! FeedlyAddNewFeedOperation(account: account, @@ -99,17 +101,18 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { addToCollectionService: caller, syncUnreadIdsService: caller, getStreamContentsService: caller, + database: container.database, container: folder, progress: progress, log: support.log) // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - addNewFeed.completionBlock = { + addNewFeed.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(addNewFeed) + MainThreadOperationQueue.shared.addOperation(addNewFeed) XCTAssert(progress.numberRemaining > 0) @@ -120,12 +123,13 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { XCTAssert(progress.isComplete) } - func testAddNewFeedSuccess() { + func testAddNewFeedSuccess() throws { guard let folder = getFolderByLoadingInitialContent() else { return } let progress = DownloadProgress(numberOfTasks: 0) + let container = support.makeTestDatabaseContainer() let _ = expectationForCompletion(of: progress) let subdirectory = "feedly-add-new-feed" @@ -145,17 +149,18 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { addToCollectionService: caller, syncUnreadIdsService: caller, getStreamContentsService: caller, + database: container.database, container: folder, progress: progress, log: support.log) // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - addNewFeed.completionBlock = { + addNewFeed.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(addNewFeed) + MainThreadOperationQueue.shared.addOperation(addNewFeed) XCTAssert(progress.numberRemaining > 0) @@ -163,7 +168,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { XCTAssert(progress.isComplete) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "feedStream", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "feedStream", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) } @@ -191,6 +196,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { } let progress = DownloadProgress(numberOfTasks: 0) + let container = support.makeTestDatabaseContainer() let _ = expectationForCompletion(of: progress) let subdirectory = "feedly-add-new-feed" @@ -220,17 +226,18 @@ class FeedlyAddNewFeedOperationTests: XCTestCase { addToCollectionService: service, syncUnreadIdsService: caller, getStreamContentsService: caller, + database: container.database, container: folder, progress: progress, log: support.log) // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - addNewFeed.completionBlock = { + addNewFeed.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(addNewFeed) + MainThreadOperationQueue.shared.addOperation(addNewFeed) XCTAssert(progress.numberRemaining > 0) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift index 074b8f7cc..565edb8d2 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyCheckpointOperationTests: XCTestCase { @@ -28,11 +29,11 @@ class FeedlyCheckpointOperationTests: XCTestCase { operation.checkpointDelegate = delegate let didFinishExpectation = expectation(description: "Did Finish") - operation.completionBlock = { + operation.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(operation) + MainThreadOperationQueue.shared.add(operation) waitForExpectations(timeout: 2) } @@ -48,13 +49,13 @@ class FeedlyCheckpointOperationTests: XCTestCase { operation.checkpointDelegate = delegate let didFinishExpectation = expectation(description: "Did Finish") - operation.completionBlock = { + operation.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(operation) + MainThreadOperationQueue.shared.add(operation) - operation.cancel() + MainThreadOperationQueue.shared.cancelOperations([operation]) waitForExpectations(timeout: 1) } diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyCollectionParserTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyCollectionParserTests.swift new file mode 100644 index 000000000..3c732a821 --- /dev/null +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyCollectionParserTests.swift @@ -0,0 +1,28 @@ +// +// FeedlyCollectionParserTests.swift +// AccountTests +// +// Created by Kiel Gillard on 29/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import Account + +class FeedlyCollectionParserTests: XCTestCase { + + func testParsing() { + let collection = FeedlyCollection(feeds: [], label: "Test Collection", id: "test/collection/1") + let parser = FeedlyCollectionParser(collection: collection) + XCTAssertEqual(parser.folderName, collection.label) + XCTAssertEqual(parser.externalID, collection.id) + } + + func testSanitization() { + let name = "Test Collection" + let collection = FeedlyCollection(feeds: [], label: "
\(name)
", id: "test/collection/1") + let parser = FeedlyCollectionParser(collection: collection) + XCTAssertEqual(parser.folderName, name) + XCTAssertEqual(parser.externalID, collection.id) + } +} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift index 92eb0279f..0ac1bd8b3 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase { @@ -54,13 +55,13 @@ class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase { let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - createFeeds.completionBlock = { + createFeeds.completionBlock = { _ in completionExpectation.fulfill() } XCTAssertTrue(account.flattenedWebFeeds().isEmpty, "Expected empty account.") - OperationQueue.main.addOperation(createFeeds) + MainThreadOperationQueue.shared.addOperation(createFeeds) waitForExpectations(timeout: 2) @@ -125,13 +126,13 @@ class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase { let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - createFeeds.completionBlock = { + createFeeds.completionBlock = { _ in completionExpectation.fulfill() } XCTAssertTrue(account.flattenedWebFeeds().isEmpty, "Expected empty account.") - OperationQueue.main.addOperation(createFeeds) + MainThreadOperationQueue.shared.addOperation(createFeeds) waitForExpectations(timeout: 2) } @@ -149,11 +150,11 @@ class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase { let removeFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - removeFeeds.completionBlock = { + removeFeeds.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(removeFeeds) + MainThreadOperationQueue.shared.addOperation(removeFeeds) waitForExpectations(timeout: 2) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyEntryParserTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyEntryParserTests.swift new file mode 100644 index 000000000..1329b4339 --- /dev/null +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyEntryParserTests.swift @@ -0,0 +1,196 @@ +// +// FeedlyEntryParserTests.swift +// AccountTests +// +// Created by Kiel Gillard on 29/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import Account + +class FeedlyEntryParserTests: XCTestCase { + + func testParsing() { + let content = FeedlyEntry.Content(content: "Test Content", direction: .leftToRight) + let summary = FeedlyEntry.Content(content: "Test Summary", direction: .leftToRight) + let origin = FeedlyOrigin(title: "Test Feed", streamId: "tests://feeds/1", htmlUrl: nil) + let canonicalLink = FeedlyLink(href: "tests://feeds/1/entries/1", type: "text/html") + let tags = [ + FeedlyTag(id: "tests/tags/1", label: "Tag 1"), + FeedlyTag(id: "tests/tags/2", label: "Tag 2") + ] + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "Test Entry 1", + content: content, + summary: summary, + author: "Bob Alice", + crawled: .distantPast, + recrawled: Date(timeIntervalSinceReferenceDate: 0), + origin: origin, + canonical: [canonicalLink], + alternate: nil, + unread: false, + tags: tags, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + XCTAssertEqual(parser.id, entry.id) + XCTAssertEqual(parser.feedUrl, origin.streamId) + XCTAssertEqual(parser.externalUrl, canonicalLink.href) + XCTAssertEqual(parser.title, entry.title) + XCTAssertEqual(parser.contentHMTL, content.content) + XCTAssertEqual(parser.summary, summary.content) + XCTAssertEqual(parser.datePublished, .distantPast) + XCTAssertEqual(parser.dateModified, Date(timeIntervalSinceReferenceDate: 0)) + + guard let item = parser.parsedItemRepresentation else { + XCTFail("Expected a parsed item representation.") + return + } + + XCTAssertEqual(item.syncServiceID, entry.id) + XCTAssertEqual(item.uniqueID, entry.id) + + // The following is not an error. + // The feedURL must match the webFeedID for the article to be connected to its matching feed. + XCTAssertEqual(item.feedURL, origin.streamId) + XCTAssertEqual(item.title, entry.title) + XCTAssertEqual(item.contentHTML, content.content) + XCTAssertEqual(item.contentText, nil, "Is it now free of HTML characters?") + XCTAssertEqual(item.summary, summary.content) + XCTAssertEqual(item.datePublished, entry.crawled) + XCTAssertEqual(item.dateModified, entry.recrawled) + + let expectedTags = Set(tags.compactMap { $0.label }) + XCTAssertEqual(item.tags, expectedTags) + + let expectedAuthors = Set([entry.author]) + let calculatedAuthors = Set(item.authors?.compactMap { $0.name } ?? []) + XCTAssertEqual(calculatedAuthors, expectedAuthors) + } + + func testSanitization() { + let content = FeedlyEntry.Content(content: "
Test Content
", direction: .rightToLeft) + let summaryContent = "Test Summary" + let summary = FeedlyEntry.Content(content: "
\(summaryContent)
", direction: .rightToLeft) + let origin = FeedlyOrigin(title: "Test Feed", streamId: "tests://feeds/1", htmlUrl: nil) + let title = "Test Entry 1" + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "
\(title)
", + content: content, + summary: summary, + author: nil, + crawled: .distantPast, + recrawled: nil, + origin: origin, + canonical: nil, + alternate: nil, + unread: false, + tags: nil, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + // These should be sanitized + XCTAssertEqual(parser.title, title) + XCTAssertEqual(parser.summary, summaryContent) + + // These should not be sanitized because it is supposed to be HTML content. + XCTAssertEqual(parser.contentHMTL, content.content) + } + + func testLocatesCanonicalExternalUrl() { + let canonicalLink = FeedlyLink(href: "tests://feeds/1/entries/1", type: "text/html") + let alternateLink = FeedlyLink(href: "tests://feeds/1/entries/alternate/1", type: "text/html") + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "Test Entry 1", + content: nil, + summary: nil, + author: nil, + crawled: .distantPast, + recrawled: Date(timeIntervalSinceReferenceDate: 0), + origin: nil, + canonical: [canonicalLink], + alternate: [alternateLink], + unread: false, + tags: nil, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + XCTAssertEqual(parser.externalUrl, canonicalLink.href) + } + + func testLocatesAlternateExternalUrl() { + let canonicalLink = FeedlyLink(href: "tests://feeds/1/entries/1", type: "text/json") + let alternateLink = FeedlyLink(href: "tests://feeds/1/entries/alternate/1", type: nil) + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "Test Entry 1", + content: nil, + summary: nil, + author: nil, + crawled: .distantPast, + recrawled: Date(timeIntervalSinceReferenceDate: 0), + origin: nil, + canonical: [canonicalLink], + alternate: [alternateLink], + unread: false, + tags: nil, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + XCTAssertEqual(parser.externalUrl, alternateLink.href) + } + + func testContentPreferredToSummary() { + let content = FeedlyEntry.Content(content: "Test Content", direction: .leftToRight) + let summary = FeedlyEntry.Content(content: "Test Summary", direction: .leftToRight) + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "Test Entry 1", + content: content, + summary: summary, + author: nil, + crawled: .distantPast, + recrawled: Date(timeIntervalSinceReferenceDate: 0), + origin: nil, + canonical: nil, + alternate: nil, + unread: false, + tags: nil, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + XCTAssertEqual(parser.contentHMTL, content.content) + } + + func testSummaryUsedAsContentWhenContentMissing() { + let summary = FeedlyEntry.Content(content: "Test Summary", direction: .leftToRight) + let entry = FeedlyEntry(id: "tests/feeds/1/entries/1", + title: "Test Entry 1", + content: nil, + summary: summary, + author: nil, + crawled: .distantPast, + recrawled: Date(timeIntervalSinceReferenceDate: 0), + origin: nil, + canonical: nil, + alternate: nil, + unread: false, + tags: nil, + categories: nil, + enclosure: nil) + + let parser = FeedlyEntryParser(entry: entry) + + XCTAssertEqual(parser.contentHMTL, summary.content) + } +} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyFeedParserTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyFeedParserTests.swift new file mode 100644 index 000000000..8ddd4aaec --- /dev/null +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyFeedParserTests.swift @@ -0,0 +1,41 @@ +// +// FeedlyFeedParserTests.swift +// AccountTests +// +// Created by Kiel Gillard on 29/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import Account + +class FeedlyFeedParserTests: XCTestCase { + + func testParsing() { + let name = "Test Feed" + let website = "tests://nnw/feed/1" + let url = "tests://nnw/feed.xml" + let id = "feed/\(url)" + let updated = Date.distantPast + let feed = FeedlyFeed(id: id, title: name, updated: updated, website: website) + let parser = FeedlyFeedParser(feed: feed) + XCTAssertEqual(parser.title, name) + XCTAssertEqual(parser.homePageURL, website) + XCTAssertEqual(parser.url, url) + XCTAssertEqual(parser.webFeedID, id) + } + + func testSanitization() { + let name = "Test Feed" + let website = "tests://nnw/feed/1" + let url = "tests://nnw/feed.xml" + let id = "feed/\(url)" + let updated = Date.distantPast + let feed = FeedlyFeed(id: id, title: "
\(name)
", updated: updated, website: website) + let parser = FeedlyFeedParser(feed: feed) + XCTAssertEqual(parser.title, name) + XCTAssertEqual(parser.homePageURL, website) + XCTAssertEqual(parser.url, url) + XCTAssertEqual(parser.webFeedID, id) + } +} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift index bf8e5bd80..e64a2a421 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import os.log +import RSCore class FeedlyGetCollectionsOperationTests: XCTestCase { @@ -20,11 +21,11 @@ class FeedlyGetCollectionsOperationTests: XCTestCase { let getCollections = FeedlyGetCollectionsOperation(service: caller, log: support.log) let completionExpectation = expectation(description: "Did Finish") - getCollections.completionBlock = { + getCollections.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getCollections) + MainThreadOperationQueue.shared.add(getCollections) waitForExpectations(timeout: 2) @@ -78,11 +79,11 @@ class FeedlyGetCollectionsOperationTests: XCTestCase { getCollections.delegate = delegate let completionExpectation = expectation(description: "Did Finish") - getCollections.completionBlock = { + getCollections.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getCollections) + MainThreadOperationQueue.shared.add(getCollections) waitForExpectations(timeout: 2) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift index 548da3c2c..489705a92 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyGetStreamContentsOperationTests: XCTestCase { @@ -35,11 +36,11 @@ class FeedlyGetStreamContentsOperationTests: XCTestCase { service.mockResult = .failure(URLError(.fileDoesNotExist)) let completionExpectation = expectation(description: "Did Finish") - getStreamContents.completionBlock = { + getStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamContents) + MainThreadOperationQueue.shared.addOperation(getStreamContents) waitForExpectations(timeout: 2) @@ -68,11 +69,11 @@ class FeedlyGetStreamContentsOperationTests: XCTestCase { } let completionExpectation = expectation(description: "Did Finish") - getStreamContents.completionBlock = { + getStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamContents) + MainThreadOperationQueue.shared.addOperation(getStreamContents) waitForExpectations(timeout: 2) @@ -100,11 +101,11 @@ class FeedlyGetStreamContentsOperationTests: XCTestCase { let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: caller, continuation: nil, newerThan: nil, unreadOnly: nil, log: support.log) let completionExpectation = expectation(description: "Did Finish") - getStreamContents.completionBlock = { + getStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamContents) + MainThreadOperationQueue.shared.addOperation(getStreamContents) waitForExpectations(timeout: 2) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift index eba39a034..3df1e1584 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyGetStreamIdsOperationTests: XCTestCase { @@ -35,11 +36,11 @@ class FeedlyGetStreamIdsOperationTests: XCTestCase { service.mockResult = .failure(URLError(.fileDoesNotExist)) let completionExpectation = expectation(description: "Did Finish") - getStreamIds.completionBlock = { + getStreamIds.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamIds) + MainThreadOperationQueue.shared.addOperation(getStreamIds) waitForExpectations(timeout: 2) @@ -68,11 +69,11 @@ class FeedlyGetStreamIdsOperationTests: XCTestCase { } let completionExpectation = expectation(description: "Did Finish") - getStreamIds.completionBlock = { + getStreamIds.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamIds) + MainThreadOperationQueue.shared.addOperation(getStreamIds) waitForExpectations(timeout: 2) @@ -95,11 +96,11 @@ class FeedlyGetStreamIdsOperationTests: XCTestCase { let getStreamIds = FeedlyGetStreamIdsOperation(account: account, resource: resource, service: caller, continuation: nil, newerThan: nil, unreadOnly: nil, log: support.log) let completionExpectation = expectation(description: "Did Finish") - getStreamIds.completionBlock = { + getStreamIds.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(getStreamIds) + MainThreadOperationQueue.shared.addOperation(getStreamIds) waitForExpectations(timeout: 2) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyLogoutOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyLogoutOperationTests.swift index 6198bad87..91a0ecd0a 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyLogoutOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyLogoutOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyLogoutOperationTests: XCTestCase { @@ -68,18 +69,17 @@ class FeedlyLogoutOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - logout.completionBlock = { + logout.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(logout) + MainThreadOperationQueue.shared.addOperation(logout) - logout.cancel() + MainThreadOperationQueue.shared.cancelOperations([logout]) waitForExpectations(timeout: 1) - XCTAssertTrue(logout.isCancelled) - XCTAssertTrue(logout.isFinished) + XCTAssertTrue(logout.isCanceled) do { let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken) @@ -101,15 +101,15 @@ class FeedlyLogoutOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - logout.completionBlock = { + logout.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(logout) + MainThreadOperationQueue.shared.addOperation(logout) waitForExpectations(timeout: 1) - XCTAssertFalse(logout.isCancelled) + XCTAssertFalse(logout.isCanceled) do { let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken) @@ -147,15 +147,15 @@ class FeedlyLogoutOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - logout.completionBlock = { + logout.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(logout) + MainThreadOperationQueue.shared.addOperation(logout) waitForExpectations(timeout: 1) - XCTAssertFalse(logout.isCancelled) + XCTAssertFalse(logout.isCanceled) do { let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken) @@ -193,15 +193,15 @@ class FeedlyLogoutOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - logout.completionBlock = { + logout.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(logout) + MainThreadOperationQueue.shared.addOperation(logout) waitForExpectations(timeout: 1) - XCTAssertFalse(logout.isCancelled) + XCTAssertFalse(logout.isCanceled) do { let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift index 9ffff7a72..de8efb630 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { @@ -37,14 +38,14 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { let provider = CollectionsProvider() let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - mirrorOperation.completionBlock = { + mirrorOperation.completionBlock = { _ in completionExpectation.fulfill() } XCTAssertTrue(mirrorOperation.collectionsAndFolders.isEmpty) XCTAssertTrue(mirrorOperation.feedsAndFolders.isEmpty) - OperationQueue.main.addOperation(mirrorOperation) + MainThreadOperationQueue.shared.addOperation(mirrorOperation) waitForExpectations(timeout: 2) @@ -69,11 +70,11 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { do { let addFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - addFolders.completionBlock = { + addFolders.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(addFolders) + MainThreadOperationQueue.shared.addOperation(addFolders) waitForExpectations(timeout: 2) } @@ -83,11 +84,11 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - removeFolders.completionBlock = { + removeFolders.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(removeFolders) + MainThreadOperationQueue.shared.addOperation(removeFolders) waitForExpectations(timeout: 2) @@ -131,11 +132,11 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { let provider = CollectionsAndFeedsProvider() let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - mirrorOperation.completionBlock = { + mirrorOperation.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(mirrorOperation) + MainThreadOperationQueue.shared.addOperation(mirrorOperation) waitForExpectations(timeout: 2) @@ -172,14 +173,14 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { let addFoldersAndFeeds = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addFoldersAndFeeds, log: support.log) - createFeeds.addDependency(addFoldersAndFeeds) + MainThreadOperationQueue.shared.make(createFeeds, dependOn: addFoldersAndFeeds) let completionExpectation = expectation(description: "Did Finish") - createFeeds.completionBlock = { + createFeeds.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperations([addFoldersAndFeeds, createFeeds], waitUntilFinished: false) + MainThreadOperationQueue.shared.addOperations([addFoldersAndFeeds, createFeeds]) waitForExpectations(timeout: 2) @@ -192,11 +193,11 @@ class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - removeFolders.completionBlock = { + removeFolders.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(removeFolders) + MainThreadOperationQueue.shared.addOperation(removeFolders) waitForExpectations(timeout: 2) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyOperationTests.swift index 4d55b0c3f..7492baa90 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSWeb +import RSCore class FeedlyOperationTests: XCTestCase { @@ -21,14 +22,15 @@ class FeedlyOperationTests: XCTestCase { var didCallMainExpectation: XCTestExpectation? var mockError: Error? - override func main() { + override func run() { + super.run() // Should always call on main thread. XCTAssertTrue(Thread.isMainThread) didCallMainExpectation?.fulfill() if let error = mockError { - didFinish(error) + didFinish(with: error) } else { didFinish() } @@ -50,7 +52,7 @@ class FeedlyOperationTests: XCTestCase { let testOperation = TestOperation() testOperation.didCallMainExpectation = expectation(description: "Did Call Main") - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) waitForExpectations(timeout: 2) } @@ -65,7 +67,7 @@ class FeedlyOperationTests: XCTestCase { testOperation.delegate = delegate - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) waitForExpectations(timeout: 2) @@ -81,23 +83,18 @@ class FeedlyOperationTests: XCTestCase { testOperation.didCallMainExpectation = expectation(description: "Did Call Main") let completionExpectation = expectation(description: "Operation Completed") - testOperation.completionBlock = { + testOperation.completionBlock = { _ in completionExpectation.fulfill() } - XCTAssertTrue(testOperation.isReady) - XCTAssertFalse(testOperation.isFinished) - XCTAssertFalse(testOperation.isExecuting) - XCTAssertFalse(testOperation.isCancelled) + + XCTAssertFalse(testOperation.isCanceled) - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) waitForExpectations(timeout: 2) - XCTAssertTrue(testOperation.isReady) - XCTAssertTrue(testOperation.isFinished) - XCTAssertFalse(testOperation.isExecuting) - XCTAssertFalse(testOperation.isCancelled) + XCTAssertFalse(testOperation.isCanceled) } func testOperationCancellationFlags() { @@ -106,43 +103,37 @@ class FeedlyOperationTests: XCTestCase { testOperation.didCallMainExpectation?.isInverted = true let completionExpectation = expectation(description: "Operation Completed") - testOperation.completionBlock = { + testOperation.completionBlock = { _ in completionExpectation.fulfill() } - XCTAssertTrue(testOperation.isReady) - XCTAssertFalse(testOperation.isFinished) - XCTAssertFalse(testOperation.isExecuting) - XCTAssertFalse(testOperation.isCancelled) + XCTAssertFalse(testOperation.isCanceled) - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) - testOperation.cancel() + MainThreadOperationQueue.shared.cancelOperations([testOperation]) waitForExpectations(timeout: 2) - XCTAssertTrue(testOperation.isReady) - XCTAssertTrue(testOperation.isFinished) - XCTAssertFalse(testOperation.isExecuting) - XCTAssertTrue(testOperation.isCancelled) + XCTAssertTrue(testOperation.isCanceled) } 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) +// let testOperation = TestOperation() +// testOperation.didCallMainExpectation = expectation(description: "Did Call Main") +// +// let dependencyExpectation = expectation(description: "Did Call Dependency") +// let blockOperation = BlockOperation { +// dependencyExpectation.fulfill() +// } +// +// MainThreadOperationQueue.shared.make(blockOperation, dependOn: testOperation) +// +// //XCTAssertTrue(blockOperation.dependencies.contains(testOperation)) +// +// MainThreadOperationQueue.shared.addOperations([testOperation, blockOperation]) +// +// waitForExpectations(timeout: 2) } func testProgressReporting() { @@ -174,15 +165,15 @@ class FeedlyOperationTests: XCTestCase { testOperation.downloadProgress = progress let completionExpectation = expectation(description: "Operation Completed") - testOperation.completionBlock = { + testOperation.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) XCTAssertTrue(progress.numberRemaining == 1) - testOperation.cancel() - XCTAssertTrue(progress.numberRemaining == 1) + MainThreadOperationQueue.shared.cancelOperations([testOperation]) + XCTAssertTrue(progress.numberRemaining == 0) waitForExpectations(timeout: 2) @@ -200,11 +191,11 @@ class FeedlyOperationTests: XCTestCase { testOperation.downloadProgress = progress let completionExpectation = expectation(description: "Operation Completed") - testOperation.completionBlock = { + testOperation.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) XCTAssertTrue(progress.numberRemaining == 1) @@ -225,11 +216,11 @@ class FeedlyOperationTests: XCTestCase { testOperation.downloadProgress = progress let completionExpectation = expectation(description: "Operation Completed") - testOperation.completionBlock = { + testOperation.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(testOperation) + MainThreadOperationQueue.shared.add(testOperation) XCTAssertTrue(progress.numberRemaining == 1) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift index 71b29f0b6..1efe9725b 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSParser +import RSCore class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { @@ -28,6 +29,7 @@ class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { } struct TestParsedItemsProvider: FeedlyParsedItemProviding { + let parsedItemProviderName = "TestParsedItemsProvider" var resource: FeedlyResourceId var parsedEntries: Set } @@ -41,17 +43,16 @@ class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - organise.completionBlock = { + organise.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(organise) + MainThreadOperationQueue.shared.addOperation(organise) waitForExpectations(timeout: 2) let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId XCTAssertEqual(itemsAndFeedIds, entries) - XCTAssertEqual(resource.id, organise.providerName) } func testGroupsOneEntryByFeedId() { @@ -63,17 +64,16 @@ class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - organise.completionBlock = { + organise.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(organise) + MainThreadOperationQueue.shared.addOperation(organise) waitForExpectations(timeout: 2) let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId XCTAssertEqual(itemsAndFeedIds, entries) - XCTAssertEqual(resource.id, organise.providerName) } func testGroupsManyEntriesByFeedId() { @@ -85,16 +85,15 @@ class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { let organise = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - organise.completionBlock = { + organise.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(organise) + MainThreadOperationQueue.shared.addOperation(organise) waitForExpectations(timeout: 2) let itemsAndFeedIds = organise.parsedItemsKeyedByFeedId XCTAssertEqual(itemsAndFeedIds, entries) - XCTAssertEqual(resource.id, organise.providerName) } } diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift index ee4991531..245d34e69 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSWeb +import RSCore class FeedlyRefreshAccessTokenOperationTests: XCTestCase { @@ -56,17 +57,17 @@ class FeedlyRefreshAccessTokenOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - refresh.completionBlock = { + refresh.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(refresh) + MainThreadOperationQueue.shared.addOperation(refresh) - refresh.cancel() + MainThreadOperationQueue.shared.cancelOperations([refresh]) waitForExpectations(timeout: 1) - XCTAssertTrue(refresh.isCancelled) + XCTAssertTrue(refresh.isCanceled) } class TestRefreshTokenDelegate: FeedlyOperationDelegate { @@ -95,11 +96,11 @@ class FeedlyRefreshAccessTokenOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - refresh.completionBlock = { + refresh.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(refresh) + MainThreadOperationQueue.shared.addOperation(refresh) waitForExpectations(timeout: 1) @@ -142,11 +143,11 @@ class FeedlyRefreshAccessTokenOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - refresh.completionBlock = { + refresh.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(refresh) + MainThreadOperationQueue.shared.addOperation(refresh) waitForExpectations(timeout: 1) @@ -196,11 +197,11 @@ class FeedlyRefreshAccessTokenOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - refresh.completionBlock = { + refresh.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(refresh) + MainThreadOperationQueue.shared.addOperation(refresh) waitForExpectations(timeout: 1) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift index d508f6635..fc834e802 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift @@ -10,6 +10,7 @@ import XCTest @testable import Account import SyncDatabase import Articles +import RSCore class FeedlySendArticleStatusesOperationTests: XCTestCase { @@ -36,11 +37,11 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) } @@ -50,7 +51,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -66,15 +68,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), 0) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, 0) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendUnreadFailure() { @@ -82,7 +94,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -98,15 +111,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), statuses.count) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, statuses.count) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendReadSuccess() { @@ -114,7 +137,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -130,15 +154,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), 0) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, 0) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendReadFailure() { @@ -146,7 +180,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -162,15 +197,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), statuses.count) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, statuses.count) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendStarredSuccess() { @@ -178,7 +223,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -194,15 +240,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), 0) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, 0) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendStarredFailure() { @@ -210,7 +266,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -226,15 +283,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), statuses.count) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, statuses.count) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendUnstarredSuccess() { @@ -242,7 +309,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -258,15 +326,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), 0) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, 0) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendUnstarredFailure() { @@ -274,7 +352,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -290,15 +369,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), statuses.count) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let expectedCount = try result.get() + XCTAssertEqual(expectedCount, statuses.count) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendAllSuccess() { @@ -313,7 +402,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -339,14 +429,25 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), 0) + + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, 0) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } func testSendAllFailure() { @@ -361,7 +462,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { } let insertExpectation = expectation(description: "Inserted Statuses") - container.database.insertStatuses(statuses) { + container.database.insertStatuses(statuses) { error in + XCTAssertNil(error) insertExpectation.fulfill() } @@ -388,14 +490,24 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase { let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log) let didFinishExpectation = expectation(description: "Did Finish") - send.completionBlock = { + send.completionBlock = { _ in didFinishExpectation.fulfill() } - OperationQueue.main.addOperation(send) + MainThreadOperationQueue.shared.addOperation(send) waitForExpectations(timeout: 2) - XCTAssertEqual(container.database.selectPendingCount(), statuses.count) + let selectPendingCountExpectation = expectation(description: "Did Select Pending Count") + container.database.selectPendingCount { result in + do { + let statusCount = try result.get() + XCTAssertEqual(statusCount, statuses.count) + selectPendingCountExpectation.fulfill() + } catch { + XCTFail("Error unwrapping database result: \(error)") + } + } + waitForExpectations(timeout: 2) } } diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySetStarredArticlesOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySetStarredArticlesOperationTests.swift deleted file mode 100644 index e86ba2de3..000000000 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySetStarredArticlesOperationTests.swift +++ /dev/null @@ -1,437 +0,0 @@ -// -// 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 - } - - func testEmptyArticleIds() { - let testIds = Set() - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertTrue(accountArticlesIDs.isEmpty) - XCTAssertEqual(accountArticlesIDs, testIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetOneArticleIdStarred() { - let testIds = Set(["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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs.count, testIds.count) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetManyArticleIdsStarred() { - let testIds = Set((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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs.count, testIds.count) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetSomeArticleIdsUnstarred() { - let initialStarredIds = Set((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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { remainingAccountArticlesIDs in - XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetAllArticleIdsUnstarred() { - let initialStarredIds = Set((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() - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { remainingAccountArticlesIDs in - XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - // MARK: - Updating Article Unread Status - - struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding { - var providerName: String - var parsedItemsKeyedByFeedId: [String: Set] - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingStarredIds) - - let idsOfStarredArticles = Set(self.account - .fetchArticles(.articleIDs(remainingStarredIds)) - .filter { $0.status.boolStatus(forKey: .starred) == true } - .map { $0.articleID }) - - XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingStarredIds) - - let idsOfStarredArticles = Set(self.account - .fetchArticles(.articleIDs(remainingStarredIds)) - .filter { $0.status.boolStatus(forKey: .starred) == true } - .map { $0.articleID }) - - XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingStarredIds) - - let idsOfStarredArticles = Set(self.account - .fetchArticles(.articleIDs(remainingStarredIds)) - .filter { $0.status.boolStatus(forKey: .starred) == true } - .map { $0.articleID }) - - XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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() - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingStarredIds) - - let idsOfStarredArticles = Set(self.account - .fetchArticles(.articleIDs(remainingStarredIds)) - .filter { $0.status.boolStatus(forKey: .starred) == true } - .map { $0.articleID }) - - XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingStarredIds) - - let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value }) - let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID }) - let idsOfStarredArticles = Set(self.account - .fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles)) - .filter { $0.status.boolStatus(forKey: .starred) == true } - .map { $0.articleID }) - - XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles) - - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } -} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySetUnreadArticlesOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySetUnreadArticlesOperationTests.swift deleted file mode 100644 index e7e936e85..000000000 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySetUnreadArticlesOperationTests.swift +++ /dev/null @@ -1,435 +0,0 @@ -// -// 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 - } - - func testEmptyArticleIds() { - let testIds = Set() - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertTrue(accountArticlesIDs.isEmpty) - XCTAssertEqual(accountArticlesIDs.count, testIds.count) - fetchIdsExpectation.fulfill() - } - - waitForExpectations(timeout: 2) - } - - func testSetOneArticleIdUnread() { - let testIds = Set(["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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs.count, testIds.count) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetManyArticleIdsUnread() { - let testIds = Set((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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs.count, testIds.count) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetSomeArticleIdsRead() { - let initialUnreadIds = Set((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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in - XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - func testSetAllArticleIdsRead() { - let initialUnreadIds = Set((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() - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in - XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - // MARK: - Updating Article Unread Status - - struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding { - var providerName: String - var parsedItemsKeyedByFeedId: [String: Set] - } - - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) - let idsOfUnreadArticles = Set(self.account - .fetchArticles(.articleIDs(remainingUnreadIds)) - .filter { $0.status.boolStatus(forKey: .read) == false } - .map { $0.articleID }) - - XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) - - let idsOfUnreadArticles = Set(self.account - .fetchArticles(.articleIDs(remainingUnreadIds)) - .filter { $0.status.boolStatus(forKey: .read) == false } - .map { $0.articleID }) - - XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - } - - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) - - let idsOfUnreadArticles = Set(self.account - .fetchArticles(.articleIDs(remainingUnreadIds)) - .filter { $0.status.boolStatus(forKey: .read) == false } - .map { $0.articleID }) - - XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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() - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) - - let idsOfUnreadArticles = Set(self.account - .fetchArticles(.articleIDs(remainingUnreadIds)) - .filter { $0.status.boolStatus(forKey: .read) == false } - .map { $0.articleID }) - - XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) - fetchIdsExpectation.fulfill() - } - } - - 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 fetchIdsExpectation = expectation(description: "Fetched Articles Ids") - account.fetchUnreadArticleIDs { accountArticlesIDs in - XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) - - let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value }) - let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID }) - let idsOfUnreadArticles = Set(self.account - .fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles)) - .filter { $0.status.boolStatus(forKey: .read) == false } - .map { $0.articleID }) - - XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles) - fetchIdsExpectation.fulfill() - } - } -} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySyncAllOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySyncAllOperationTests.swift index 914c25e8a..79263b87d 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySyncAllOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlySyncAllOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSWeb +import RSCore class FeedlySyncAllOperationTests: XCTestCase { @@ -57,29 +58,35 @@ class FeedlySyncAllOperationTests: XCTestCase { 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 getStarredIds = TestGetStreamIdsService() + getStarredIds.getStreamIdsExpectation = expectation(description: "Get Ids of global.saved") + getStarredIds.getStreamIdsExpectation?.isInverted = true + + let getEntriesService = TestGetEntriesService() + getEntriesService.getEntriesExpectation = expectation(description: "Get Entries") + getEntriesService.getEntriesExpectation?.isInverted = true let progress = DownloadProgress(numberOfTasks: 0) let _ = expectationForCompletion(of: progress) 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, - downloadProgress: progress, - log: support.log) + credentials: support.accessToken, + lastSuccessfulFetchStartDate: nil, + markArticlesService: markArticlesService, + getUnreadService: getStreamIdsService, + getCollectionsService: getCollectionsService, + getStreamContentsService: getGlobalStreamContents, + getStarredService: getStarredIds, + getStreamIdsService: getStreamIdsService, + getEntriesService: getEntriesService, + database: container.database, + downloadProgress: progress, + log: support.log) // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - syncAll.completionBlock = { + syncAll.completionBlock = { _ in completionExpectation.fulfill() } @@ -96,7 +103,7 @@ class FeedlySyncAllOperationTests: XCTestCase { syncCompletionExpectation.fulfill() } - OperationQueue.main.addOperation(syncAll) + MainThreadOperationQueue.shared.addOperation(syncAll) XCTAssertTrue(progress.numberOfTasks > 1) @@ -114,18 +121,18 @@ class FeedlySyncAllOperationTests: XCTestCase { return caller }() - func testSyncing() { + func testSyncing() throws { performInitialSync() - verifyInitialSync() + try verifyInitialSync() performChangeStatuses() - verifyChangeStatuses() + try verifyChangeStatuses() performChangeStatusesAgain() - verifyChangeStatusesAgain() + try verifyChangeStatusesAgain() performAddFeedsAndFolders() - verifyAddFeedsAndFolders() + try verifyAddFeedsAndFolders() } // MARK: 1 - Initial Sync @@ -149,11 +156,11 @@ class FeedlySyncAllOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - syncAll.completionBlock = { + syncAll.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(syncAll) + MainThreadOperationQueue.shared.addOperation(syncAll) XCTAssertTrue(progress.numberOfTasks > 1) @@ -166,15 +173,15 @@ class FeedlySyncAllOperationTests: XCTestCase { loadMockData(inSubdirectoryNamed: "feedly-1-initial") } - func verifyInitialSync() { + func verifyInitialSync() throws { 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) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all@MTZkOTdkZWQ1NzM6NTE2OjUzYjgyNmEy", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTRhOTNhZTQ6MzExOjUzYjgyNmEy", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) } // MARK: 2 - Change Statuses @@ -183,14 +190,14 @@ class FeedlySyncAllOperationTests: XCTestCase { loadMockData(inSubdirectoryNamed: "feedly-2-changestatuses") } - func verifyChangeStatuses() { + func verifyChangeStatuses() throws { let subdirectory = "feedly-2-changestatuses" support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTJkNjIwM2Q6MTEzYjpkNDUwNjA3MQ==", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) } // MARK: 3 - Change Statuses Again @@ -199,14 +206,14 @@ class FeedlySyncAllOperationTests: XCTestCase { loadMockData(inSubdirectoryNamed: "feedly-3-changestatusesagain") } - func verifyChangeStatusesAgain() { + func verifyChangeStatusesAgain() throws { let subdirectory = "feedly-3-changestatusesagain" support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YyOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) } // MARK: 4 - Add Feeds and Folders @@ -215,14 +222,14 @@ class FeedlySyncAllOperationTests: XCTestCase { loadMockData(inSubdirectoryNamed: "feedly-4-addfeedsandfolders") } - func verifyAddFeedsAndFolders() { + func verifyAddFeedsAndFolders() throws { let subdirectory = "feedly-4-addfeedsandfolders" support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTE3YTRlMzQ6YWZjOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) } // MARK: 5 - Remove Feeds and Folders @@ -231,14 +238,14 @@ class FeedlySyncAllOperationTests: XCTestCase { loadMockData(inSubdirectoryNamed: "feedly-5-removefeedsandfolders") } - func verifyRemoveFeedsAndFolders() { + func verifyRemoveFeedsAndFolders() throws { let subdirectory = "feedly-5-removefeedsandfolders" support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YxOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) - support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) + try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) } // MARK: Downloading Test Data @@ -260,13 +267,13 @@ class FeedlySyncAllOperationTests: XCTestCase { // If this expectation is not fulfilled, the operation is not calling `didFinish`. let completionExpectation = expectation(description: "Did Finish") - syncAll.completionBlock = { + syncAll.completionBlock = { _ in completionExpectation.fulfill() } lastSuccessfulFetchStartDate = Date() - OperationQueue.main.addOperation(syncAll) + MainThreadOperationQueue.shared.addOperation(syncAll) XCTAssertTrue(progress.numberOfTasks > 1) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySyncStarredArticlesOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySyncStarredArticlesOperationTests.swift deleted file mode 100644 index 761dd8a01..000000000 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySyncStarredArticlesOperationTests.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { starredArticleIds in - 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 = self.account.fetchArticles(.articleIDs(expectedArticleIds)) - XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") - - let starredArticles = self.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) - - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { starredArticleIds in - XCTAssertTrue(starredArticleIds.isEmpty) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchStarredArticleIDs { starredArticleIds in - 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 = self.account.fetchArticles(.articleIDs(expectedArticleIds)) - XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") - - let starredArticles = self.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) - - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } -} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift index 52d03bef4..897d7492c 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import Account +import RSCore class FeedlySyncStreamContentsOperationTests: XCTestCase { @@ -26,7 +27,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase { super.tearDown() } - func testIngestsOnePageSuccess() { + func testIngestsOnePageSuccess() throws { let service = TestGetStreamContentsService() let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678") let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0) @@ -44,19 +45,19 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase { XCTAssertNil(serviceUnreadOnly) } - let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log) + let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log) let completionExpectation = expectation(description: "Did Finish") - syncStreamContents.completionBlock = { + syncStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(syncStreamContents) + MainThreadOperationQueue.shared.addOperation(syncStreamContents) waitForExpectations(timeout: 2) let expectedArticleIds = Set(items.map { $0.id }) - let expectedArticles = account.fetchArticles(.articleIDs(expectedArticleIds)) + let expectedArticles = try account.fetchArticles(.articleIDs(expectedArticleIds)) XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") } @@ -78,19 +79,19 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase { XCTAssertNil(serviceUnreadOnly) } - let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log) + let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log) let completionExpectation = expectation(description: "Did Finish") - syncStreamContents.completionBlock = { + syncStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(syncStreamContents) + MainThreadOperationQueue.shared.addOperation(syncStreamContents) waitForExpectations(timeout: 2) } - func testIngestsManyPagesSuccess() { + func testIngestsManyPagesSuccess() throws { let service = TestGetPagedStreamContentsService() let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678") let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0) @@ -119,20 +120,20 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase { getStreamPageExpectation.fulfill() } - let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, newerThan: newerThan, log: support.log) + let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log) let completionExpectation = expectation(description: "Did Finish") - syncStreamContents.completionBlock = { + syncStreamContents.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(syncStreamContents) + MainThreadOperationQueue.shared.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)) + let articles = try account.fetchArticles(.articleIDs(articleIds)) XCTAssertEqual(articleIds.count, articles.count) } } diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlySyncUnreadStatusesOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlySyncUnreadStatusesOperationTests.swift deleted file mode 100644 index fa4864a73..000000000 --- a/Frameworks/Account/AccountTests/Feedly/FeedlySyncUnreadStatusesOperationTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchUnreadArticleIDs { unreadArticleIds in - let missingIds = expectedArticleIds.subtracting(unreadArticleIds) - XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.") - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchUnreadArticleIDs { unreadArticleIds in - XCTAssertTrue(unreadArticleIds.isEmpty) - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } - - 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 fetchIdsExpectation = expectation(description: "Fetch Article Ids") - account.fetchUnreadArticleIDs { unreadArticleIds in - let missingIds = expectedArticleIds.subtracting(unreadArticleIds) - XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.") - fetchIdsExpectation.fulfill() - } - waitForExpectations(timeout: 2) - } -} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift index 3e5ae30ff..46117de80 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift @@ -141,13 +141,13 @@ class FeedlyTestSupport { 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) { + func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) throws { let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any] - checkArticles(in: account, againstItemsInStreamInJSONPayload: stream) + try checkArticles(in: account, againstItemsInStreamInJSONPayload: stream) } - func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) { - checkArticles(in: account, correspondToStreamItemsIn: stream) + func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) throws { + try checkArticles(in: account, correspondToStreamItemsIn: stream) } private struct ArticleItem { @@ -188,13 +188,13 @@ class FeedlyTestSupport { } /// Awkwardly titled to make it clear the JSON given is from a stream response. - func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) { + func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) throws { 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 articles = try testAccount.fetchArticles(.articleIDs(itemIds)) let articleIds = Set(articles.map { $0.articleID }) let missing = itemIds.subtracting(articleIds) @@ -220,12 +220,17 @@ class FeedlyTestSupport { func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any], testCase: XCTestCase) { let ids = Set(streamIds["ids"] as! [String]) let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids") - testAccount.fetchUnreadArticleIDs { articleIds in - // 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.") - fetchIdsExpectation.fulfill() + testAccount.fetchUnreadArticleIDs { articleIdsResult in + do { + let articleIds = try articleIdsResult.get() + // 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.") + fetchIdsExpectation.fulfill() + } catch { + XCTFail("Error unwrapping article IDs: \(error)") + } } testCase.wait(for: [fetchIdsExpectation], timeout: 2) } @@ -239,12 +244,17 @@ class FeedlyTestSupport { let items = stream["items"] as! [[String: Any]] let ids = Set(items.map { $0["id"] as! String }) let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids") - testAccount.fetchStarredArticleIDs { articleIds in - // 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.") - fetchIdsExpectation.fulfill() + testAccount.fetchStarredArticleIDs { articleIdsResult in + do { + let articleIds = try articleIdsResult.get() + // 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.") + fetchIdsExpectation.fulfill() + } catch { + XCTFail("Error unwrapping article IDs: \(error)") + } } testCase.wait(for: [fetchIdsExpectation], timeout: 2) } diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyTextSanitizationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyTextSanitizationTests.swift new file mode 100644 index 000000000..ae9eefba0 --- /dev/null +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyTextSanitizationTests.swift @@ -0,0 +1,38 @@ +// +// FeedlyTextSanitizationTests.swift +// AccountTests +// +// Created by Kiel Gillard on 29/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import Account + +class FeedlyTextSanitizationTests: XCTestCase { + + func testRTLSanitization() { + + let targetsAndExpectations: [(target: String?, expectation: String?)] = [ + (nil, nil), + ("", ""), + (" ", " "), + ("text", "text"), + ("
", "
"), + ("
", "
"), + ("
text", "
text"), + ("text
", "text
"), + ("
", ""), + ("
", "
"), + ("
", "
"), + ("
text
", "text"), + ] + + let sanitizer = FeedlyRTLTextSanitizer() + + for (target, expectation) in targetsAndExpectations { + let calculated = sanitizer.sanitize(target) + XCTAssertEqual(expectation, calculated) + } + } +} diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyUpdateAccountFeedsWithItemsOperationTests.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyUpdateAccountFeedsWithItemsOperationTests.swift index 7e0b4d087..304eea372 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyUpdateAccountFeedsWithItemsOperationTests.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyUpdateAccountFeedsWithItemsOperationTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import Account import RSParser +import RSCore class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { @@ -28,23 +29,23 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { } struct TestItemsByFeedProvider: FeedlyParsedItemsByFeedProviding { - var providerName: String + var parsedItemsByFeedProviderName: String var parsedItemsKeyedByFeedId: [String: Set] } - func testUpdateAccountWithEmptyItems() { + func testUpdateAccountWithEmptyItems() throws { 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 provider = TestItemsByFeedProvider(parsedItemsByFeedProviderName: resource.id, parsedItemsKeyedByFeedId: testItems) let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - update.completionBlock = { + update.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(update) + MainThreadOperationQueue.shared.addOperation(update) waitForExpectations(timeout: 2) @@ -52,23 +53,23 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { 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)) + let accountArticles = try account.fetchArticles(.articleIDs(articleIds)) XCTAssertTrue(accountArticles.isEmpty) } - func testUpdateAccountWithOneItem() { + func testUpdateAccountWithOneItem() throws { 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 provider = TestItemsByFeedProvider(parsedItemsByFeedProviderName: resource.id, parsedItemsKeyedByFeedId: testItems) let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - update.completionBlock = { + update.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(update) + MainThreadOperationQueue.shared.addOperation(update) waitForExpectations(timeout: 2) @@ -76,7 +77,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { 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)) + let accountArticles = try account.fetchArticles(.articleIDs(articleIds)) XCTAssertTrue(accountArticles.count == entries.count) let accountArticleIds = Set(accountArticles.map { $0.articleID }) @@ -84,19 +85,19 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { XCTAssertTrue(missingIds.isEmpty) } - func testUpdateAccountWithManyItems() { + func testUpdateAccountWithManyItems() throws { 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 provider = TestItemsByFeedProvider(parsedItemsByFeedProviderName: resource.id, parsedItemsKeyedByFeedId: testItems) let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - update.completionBlock = { + update.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(update) + MainThreadOperationQueue.shared.addOperation(update) waitForExpectations(timeout: 10) // 10,000 articles takes ~ three seconds for me. @@ -104,7 +105,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { 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)) + let accountArticles = try account.fetchArticles(.articleIDs(articleIds)) XCTAssertTrue(accountArticles.count == entries.count) let accountArticleIds = Set(accountArticles.map { $0.articleID }) @@ -112,21 +113,21 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { XCTAssertTrue(missingIds.isEmpty) } - func testCancelUpdateAccount() { + func testCancelUpdateAccount() throws { 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 provider = TestItemsByFeedProvider(parsedItemsByFeedProviderName: resource.id, parsedItemsKeyedByFeedId: testItems) let update = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: provider, log: support.log) let completionExpectation = expectation(description: "Did Finish") - update.completionBlock = { + update.completionBlock = { _ in completionExpectation.fulfill() } - OperationQueue.main.addOperation(update) + MainThreadOperationQueue.shared.addOperation(update) - update.cancel() + MainThreadOperationQueue.shared.cancelOperations([update]) waitForExpectations(timeout: 2) @@ -134,7 +135,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase { 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)) + let accountArticles = try account.fetchArticles(.articleIDs(articleIds)) XCTAssertTrue(accountArticles.isEmpty) } } diff --git a/Frameworks/Account/AccountTests/Feedly/TestGetEntriesService.swift b/Frameworks/Account/AccountTests/Feedly/TestGetEntriesService.swift new file mode 100644 index 000000000..7af98b393 --- /dev/null +++ b/Frameworks/Account/AccountTests/Feedly/TestGetEntriesService.swift @@ -0,0 +1,26 @@ +// +// TestGetEntriesService.swift +// AccountTests +// +// Created by Kiel Gillard on 11/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import XCTest +@testable import Account + +final class TestGetEntriesService: FeedlyGetEntriesService { + var mockResult: Result<[FeedlyEntry], Error>? + var getEntriesExpectation: XCTestExpectation? + + func getEntries(for ids: Set, completion: @escaping (Result<[FeedlyEntry], 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 { + completion(result) + self.getEntriesExpectation?.fulfill() + } + } +} diff --git a/Frameworks/Account/ContainerIdentifier.swift b/Frameworks/Account/ContainerIdentifier.swift index 1797787c1..0db60e6d4 100644 --- a/Frameworks/Account/ContainerIdentifier.swift +++ b/Frameworks/Account/ContainerIdentifier.swift @@ -12,7 +12,7 @@ public protocol ContainerIdentifiable { var containerID: ContainerIdentifier? { get } } -public enum ContainerIdentifier: Hashable { +public enum ContainerIdentifier: Hashable, Equatable { case smartFeedController case account(String) // accountID case folder(String, String) // accountID, folderName @@ -55,3 +55,47 @@ public enum ContainerIdentifier: Hashable { } } + +extension ContainerIdentifier: Encodable { + enum CodingKeys: CodingKey { + case type + case accountID + case folderName + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .smartFeedController: + try container.encode("smartFeedController", forKey: .type) + case .account(let accountID): + try container.encode("account", forKey: .type) + try container.encode(accountID, forKey: .accountID) + case .folder(let accountID, let folderName): + try container.encode("folder", forKey: .type) + try container.encode(accountID, forKey: .accountID) + try container.encode(folderName, forKey: .folderName) + } + } +} + +extension ContainerIdentifier: Decodable { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "smartFeedController": + self = .smartFeedController + case "account": + let accountID = try container.decode(String.self, forKey: .accountID) + self = .account(accountID) + default: + let accountID = try container.decode(String.self, forKey: .accountID) + let folderName = try container.decode(String.self, forKey: .folderName) + self = .folder(accountID, folderName) + } + } + +} diff --git a/Frameworks/Account/FeedFinder/FeedFinder.swift b/Frameworks/Account/FeedFinder/FeedFinder.swift index 479598248..8044295fa 100644 --- a/Frameworks/Account/FeedFinder/FeedFinder.swift +++ b/Frameworks/Account/FeedFinder/FeedFinder.swift @@ -124,7 +124,7 @@ private extension FeedFinder { } static func isHTML(_ data: Data) -> Bool { - return (data as NSData).rs_dataIsProbablyHTML() + return data.isProbablyHTML } static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result, Error>) -> Void) { diff --git a/Frameworks/Account/FeedFinder/FeedSpecifier.swift b/Frameworks/Account/FeedFinder/FeedSpecifier.swift index ebcfab616..009c39644 100644 --- a/Frameworks/Account/FeedFinder/FeedSpecifier.swift +++ b/Frameworks/Account/FeedFinder/FeedSpecifier.swift @@ -69,10 +69,10 @@ private extension FeedSpecifier { score = score + 50 } - if urlString.rs_caseInsensitiveContains("comments") { + if urlString.caseInsensitiveContains("comments") { score = score - 10 } - if urlString.rs_caseInsensitiveContains("rss") { + if urlString.caseInsensitiveContains("rss") { score = score + 5 } if urlString.hasSuffix("/feed/") { @@ -81,15 +81,15 @@ private extension FeedSpecifier { if urlString.hasSuffix("/feed") { score = score + 4 } - if urlString.rs_caseInsensitiveContains("json") { + if urlString.caseInsensitiveContains("json") { score = score + 6 } if let title = title { - if title.rs_caseInsensitiveContains("comments") { + if title.caseInsensitiveContains("comments") { score = score - 10 } - if title.rs_caseInsensitiveContains("json") { + if title.caseInsensitiveContains("json") { score = score + 1 } } diff --git a/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift b/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift index ff4baa2a2..39c3a095b 100644 --- a/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift +++ b/Frameworks/Account/FeedFinder/HTMLFeedFinder.swift @@ -29,16 +29,14 @@ class HTMLFeedFinder { } } - if let bodyLinks = RSHTMLLinkParser.htmlLinks(with: parserData) { + let bodyLinks = RSHTMLLinkParser.htmlLinks(with: parserData) for oneBodyLink in bodyLinks { - if linkMightBeFeed(oneBodyLink) { - let normalizedURL = oneBodyLink.urlString.rs_normalizedURL() + if linkMightBeFeed(oneBodyLink), let normalizedURL = oneBodyLink.urlString?.normalizedURL { let oneFeedSpecifier = FeedSpecifier(title: oneBodyLink.text, urlString: normalizedURL, source: .HTMLLink) addFeedSpecifier(oneFeedSpecifier) } } - } } } diff --git a/Frameworks/Account/FeedIdentifier.swift b/Frameworks/Account/FeedIdentifier.swift index 1bd3230d7..eed0a7555 100644 --- a/Frameworks/Account/FeedIdentifier.swift +++ b/Frameworks/Account/FeedIdentifier.swift @@ -79,5 +79,19 @@ public enum FeedIdentifier: CustomStringConvertible, Hashable { return nil } } - + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + switch self { + case .smartFeed(let id): + hasher.combine(id) + case .script(let id): + hasher.combine(id) + case .webFeed(_, let webFeedID): + hasher.combine(webFeedID) + case .folder(_, let folderName): + hasher.combine(folderName) + } + } } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index 14d2d4e49..35818a012 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -837,14 +837,7 @@ private extension FeedbinAccountDelegate { os_log(.debug, log: log, "Syncing taggings with %ld taggings.", taggings.count) // Set up some structures to make syncing easier - let folderDict: [String: Folder] = { - if let folders = account.folders { - return Dictionary(uniqueKeysWithValues: folders.map { ($0.name ?? "", $0) } ) - } else { - return [String: Folder]() - } - }() - + let folderDict = nameToFolderDictionary(with: account.folders) let taggingsDict = taggings.reduce([String: [FeedbinTagging]]()) { (dict, tagging) in var taggedFeeds = dict if var taggedFeed = taggedFeeds[tagging.name] { @@ -897,7 +890,22 @@ private extension FeedbinAccountDelegate { } } } - + + func nameToFolderDictionary(with folders: Set?) -> [String: Folder] { + guard let folders = folders else { + return [String: Folder]() + } + + var d = [String: Folder]() + for folder in folders { + let name = folder.name ?? "" + if d[name] == nil { + d[name] = folder + } + } + return d + } + func sendArticleStatuses(_ statuses: [SyncStatus], apiCall: ([Int], @escaping (Result) -> Void) -> Void, completion: @escaping ((Result) -> Void)) { @@ -1225,7 +1233,7 @@ private extension FeedbinAccountDelegate { let parsedItems: [ParsedItem] = entries.map { entry in let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) - return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: nil, externalURL: entry.url, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) + return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) } return Set(parsedItems) @@ -1237,20 +1245,38 @@ private extension FeedbinAccountDelegate { return } - let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } ) - account.fetchUnreadArticleIDs { articleIDsResult in - guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { - return + database.selectPendingReadStatusArticleIDs() { result in + + func process(_ pendingArticleIDs: Set) { + + let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } ) + let updatableFeedbinUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(pendingArticleIDs) + + account.fetchUnreadArticleIDs { articleIDsResult in + guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as unread + let deltaUnreadArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs) + account.markAsUnread(deltaUnreadArticleIDs) + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs) + account.markAsRead(deltaReadArticleIDs) + } + } - - // Mark articles as unread - let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs) - account.markAsUnread(deltaUnreadArticleIDs) - - // Mark articles as read - let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) - account.markAsRead(deltaReadArticleIDs) + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Article Read Status failed: %@.", error.localizedDescription) + } + } + } func syncArticleStarredState(account: Account, articleIDs: [Int]?) { @@ -1258,20 +1284,38 @@ private extension FeedbinAccountDelegate { return } - let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } ) - account.fetchStarredArticleIDs { articleIDsResult in - guard let currentStarredArticleIDs = try? articleIDsResult.get() else { - return + database.selectPendingStarredStatusArticleIDs() { result in + + func process(_ pendingArticleIDs: Set) { + + let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } ) + let updatableFeedbinUnreadArticleIDs = feedbinStarredArticleIDs.subtracting(pendingArticleIDs) + + account.fetchStarredArticleIDs { articleIDsResult in + guard let currentStarredArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as starred + let deltaStarredArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentStarredArticleIDs) + account.markAsStarred(deltaStarredArticleIDs) + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs) + account.markAsUnstarred(deltaUnstarredArticleIDs) + } + + } + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription) } - // Mark articles as starred - let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs) - account.markAsStarred(deltaStarredArticleIDs) - - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) - account.markAsUnstarred(deltaUnstarredArticleIDs) } + } func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index afa4d7747..dfd101325 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -637,7 +637,7 @@ extension FeedlyAPICaller: FeedlyGetStreamIdsService { } queryItems.append(contentsOf: [ - URLQueryItem(name: "count", value: "1000"), + URLQueryItem(name: "count", value: "10000"), URLQueryItem(name: "streamId", value: resource.id), ]) diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 98af63ca2..369e237f8 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -25,7 +25,7 @@ final class FeedlyAccountDelegate: AccountDelegate { // 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/ - var behaviors: AccountBehaviors = [.disallowFeedInRootFolder] + var behaviors: AccountBehaviors = [.disallowFeedInRootFolder, .disallowMarkAsUnreadAfterPeriod(31)] let isOPMLImportSupported = false @@ -57,16 +57,14 @@ final class FeedlyAccountDelegate: AccountDelegate { private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly") private let database: SyncDatabase - private weak var currentSyncAllOperation: FeedlySyncAllOperation? - private let operationQueue: OperationQueue + private weak var currentSyncAllOperation: MainThreadOperation? + private let operationQueue = MainThreadOperationQueue() 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 { self.caller = FeedlyAPICaller(transport: transport, api: api) @@ -129,28 +127,25 @@ final class FeedlyAccountDelegate: AccountDelegate { currentSyncAllOperation = operation - operationQueue.addOperation(operation) + operationQueue.add(operation) } func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { // Ensure remote articles have the same status as they do locally. let send = FeedlySendArticleStatusesOperation(database: database, service: caller, log: log) - send.completionBlock = { + send.completionBlock = { operation in + // TODO: not call with success if operation was canceled? Not sure. DispatchQueue.main.async { completion(.success(())) } } - operationQueue.addOperation(send) + operationQueue.add(send) } /// Attempts to ensure local articles have the same status as they do remotely. /// So if the user is using another client roughly simultaneously with this app, /// this app does its part to ensure the articles have a consistent status between both. /// - /// Feedly has no API that allows the app to fetch the identifiers of unread articles only. - /// The only way to identify unread articles is to pull all of the article data, - /// which is effectively equivalent of a full refresh. - /// /// - Parameter account: The account whose articles have a remote status. /// - Parameter completion: Call on the main queue. func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { @@ -160,18 +155,18 @@ final class FeedlyAccountDelegate: AccountDelegate { let group = DispatchGroup() - let syncUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: caller, newerThan: nil, log: log) + let ingestUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: caller, database: database, newerThan: nil, log: log) group.enter() - syncUnread.completionBlock = { + ingestUnread.completionBlock = { _ in group.leave() } - let syncStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, service: caller, log: log) + let ingestStarred = FeedlyIngestStarredArticleIdsOperation(account: account, credentials: credentials, service: caller, database: database, newerThan: nil, log: log) group.enter() - syncStarred.completionBlock = { + ingestStarred.completionBlock = { _ in group.leave() } @@ -179,7 +174,7 @@ final class FeedlyAccountDelegate: AccountDelegate { completion(.success(())) } - operationQueue.addOperations([syncUnread, syncStarred], waitUntilFinished: false) + operationQueue.addOperations([ingestUnread, ingestStarred]) } func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> Void) { @@ -301,6 +296,7 @@ final class FeedlyAccountDelegate: AccountDelegate { addToCollectionService: caller, syncUnreadIdsService: caller, getStreamContentsService: caller, + database: database, container: container, progress: refreshProgress, log: log) @@ -309,7 +305,7 @@ final class FeedlyAccountDelegate: AccountDelegate { completion(result) } - operationQueue.addOperation(addNewFeed) + operationQueue.add(addNewFeed) } catch { DispatchQueue.main.async { @@ -366,7 +362,7 @@ final class FeedlyAccountDelegate: AccountDelegate { completion(result) } - operationQueue.addOperation(addExistingFeed) + operationQueue.add(addExistingFeed) } catch { DispatchQueue.main.async { @@ -498,13 +494,13 @@ final class FeedlyAccountDelegate: AccountDelegate { credentials = try? account.retrieveCredentials(type: .oauthAccessToken) let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log) - operationQueue.addOperation(refreshAccessToken) + operationQueue.add(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) + // Dispatch on the shared queue because the lifetime of the account delegate is uncertain. + MainThreadOperationQueue.shared.add(logout) } static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Feedly/FeedlyFeedContainerValidator.swift b/Frameworks/Account/Feedly/FeedlyFeedContainerValidator.swift index 62bc0c4f4..da4e6fb81 100644 --- a/Frameworks/Account/Feedly/FeedlyFeedContainerValidator.swift +++ b/Frameworks/Account/Feedly/FeedlyFeedContainerValidator.swift @@ -10,7 +10,6 @@ import Foundation struct FeedlyFeedContainerValidator { var container: Container - var userId: String? func getValidContainer() throws -> (Folder, String) { guard let folder = container as? Folder else { @@ -21,16 +20,6 @@ struct FeedlyFeedContainerValidator { throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder) } - guard let userId = userId else { - throw FeedlyAccountDelegateError.notLoggedIn - } - - let uncategorized = FeedlyCategoryResourceId.Global.uncategorized(for: userId) - - guard collectionId != uncategorized.id else { - throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder) - } - return (folder, collectionId) } } diff --git a/Frameworks/Account/Feedly/Models/FeedlyCategory.swift b/Frameworks/Account/Feedly/Models/FeedlyCategory.swift index 829a85dda..534788692 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyCategory.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyCategory.swift @@ -9,6 +9,6 @@ import Foundation struct FeedlyCategory: Decodable { - var label: String - var id: String + let label: String + let id: String } diff --git a/Frameworks/Account/Feedly/Models/FeedlyCollection.swift b/Frameworks/Account/Feedly/Models/FeedlyCollection.swift index b989c2ba3..80322ea51 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyCollection.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyCollection.swift @@ -9,7 +9,7 @@ import Foundation struct FeedlyCollection: Codable { - var feeds: [FeedlyFeed] - var label: String - var id: String + let feeds: [FeedlyFeed] + let label: String + let id: String } diff --git a/Frameworks/Account/Feedly/Models/FeedlyCollectionParser.swift b/Frameworks/Account/Feedly/Models/FeedlyCollectionParser.swift new file mode 100644 index 000000000..229498592 --- /dev/null +++ b/Frameworks/Account/Feedly/Models/FeedlyCollectionParser.swift @@ -0,0 +1,23 @@ +// +// FeedlyCollectionParser.swift +// Account +// +// Created by Kiel Gillard on 28/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedlyCollectionParser { + let collection: FeedlyCollection + + private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer() + + var folderName: String { + return rightToLeftTextSantizer.sanitize(collection.label) ?? "" + } + + var externalID: String { + return collection.id + } +} diff --git a/Frameworks/Account/Feedly/Models/FeedlyEntry.swift b/Frameworks/Account/Feedly/Models/FeedlyEntry.swift index 33a8d8c2d..cb38fd2e6 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyEntry.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyEntry.swift @@ -8,84 +8,58 @@ import Foundation -enum Direction: String, Codable { - case leftToRight = "ltr" - case rightToLeft = "rtl" -} - struct FeedlyEntry: Decodable { /// the unique, immutable ID for this particular article. - var id: String + let id: String /// the article’s title. This string does not contain any HTML markup. - var title: String? + let title: String? - struct Content: Codable { - var content: String? - var direction: Direction? + struct Content: Decodable { + + enum Direction: String, Decodable { + case leftToRight = "ltr" + case rightToLeft = "rtl" + } + + let content: String? + let direction: Direction? } /// This object typically has two values: “content” for the content itself, and “direction” (“ltr” for left-to-right, “rtl” for right-to-left). The content itself contains sanitized HTML markup. - var content: Content? + let content: Content? /// content object the article summary. See the content object above. - var summary: Content? + let summary: Content? /// the author’s name - var author: String? + let author: String? /// the immutable timestamp, in ms, when this article was processed by the feedly Cloud servers. - var crawled: Date + let crawled: Date /// the timestamp, in ms, when this article was re-processed and updated by the feedly Cloud servers. - var recrawled: Date? + let recrawled: Date? - /// the timestamp, in ms, when this article was published, as reported by the RSS feed (often inaccurate). -// var published: Date - - /// the timestamp, in ms, when this article was updated, as reported by the RSS feed -// var updated: Date? - /// the feed from which this article was crawled. If present, “streamId” will contain the feed id, “title” will contain the feed title, and “htmlUrl” will contain the feed’s website. - var origin: FeedlyOrigin? + let origin: FeedlyOrigin? /// Used to help find the URL to visit an article on a web site. /// See https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ - var canonical: [FeedlyLink]? + let canonical: [FeedlyLink]? /// a list of alternate links for this article. Each link object contains a media type and a URL. Typically, a single object is present, with a link to the original web page. - var alternate: [FeedlyLink]? -// -// // var origin: -// // Optional origin object the feed from which this article was crawled. If present, “streamId” will contain the feed id, “title” will contain the feed title, and “htmlUrl” will contain the feed’s website. -// var keywords: [String]? -// -// /// an image URL for this entry. If present, “url” will contain the image URL, “width” and “height” its dimension, and “contentType” its MIME type. -// var visual: Image? -// + let alternate: [FeedlyLink]? + /// Was this entry read by the user? If an Authorization header is not provided, this will always return false. If an Authorization header is provided, it will reflect if the user has read this entry or not. - var unread: Bool -// - /// a list of tag objects (“id” and “label”) that the user added to this entry. This value is only returned if an Authorization header is provided, and at least one tag has been added. If the entry has been explicitly marked as read (not the feed itself), the “global.read” tag will be present. - var tags: [FeedlyTag]? -// - /// a list of category objects (“id” and “label”) that the user associated with the feed of this entry. This value is only returned if an Authorization header is provided. - var categories: [FeedlyCategory]? -// -// /// an indicator of how popular this entry is. The higher the number, the more readers have read, saved or shared this particular entry. -// var engagement: Int? -// -// /// Timestamp for tagged articles, contains the timestamp when the article was tagged by the user. This will only be returned when the entry is returned through the streams API. -// var actionTimestamp: Date? -// - /// A list of media links (videos, images, sound etc) provided by the feed. Some entries do not have a summary or content, only a collection of media links. - var enclosure: [FeedlyLink]? -// -// /// The article fingerprint. This value might change if the article is updated. -// var fingerprint: String - - // originId - // string the unique id of this post in the RSS feed (not necessarily a URL!) - // sid - // Optional string an internal search id. + let unread: Bool + + /// a list of tag objects (“id” and “label”) that the user added to this entry. This value is only returned if an Authorization header is provided, and at least one tag has been added. If the entry has been explicitly marked as read (not the feed itself), the “global.read” tag will be present. + let tags: [FeedlyTag]? + + /// a list of category objects (“id” and “label”) that the user associated with the feed of this entry. This value is only returned if an Authorization header is provided. + let categories: [FeedlyCategory]? + + /// A list of media links (videos, images, sound etc) provided by the feed. Some entries do not have a summary or content, only a collection of media links. + let enclosure: [FeedlyLink]? } diff --git a/Frameworks/Account/Feedly/Models/FeedlyEntryIdentifierProviding.swift b/Frameworks/Account/Feedly/Models/FeedlyEntryIdentifierProviding.swift new file mode 100644 index 000000000..4ad0054ec --- /dev/null +++ b/Frameworks/Account/Feedly/Models/FeedlyEntryIdentifierProviding.swift @@ -0,0 +1,29 @@ +// +// FeedlyEntryIdentifierProviding.swift +// Account +// +// Created by Kiel Gillard on 9/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +protocol FeedlyEntryIdentifierProviding: class { + var entryIds: Set { get } +} + +final class FeedlyEntryIdentifierProvider: FeedlyEntryIdentifierProviding { + private (set) var entryIds: Set + + init(entryIds: Set = Set()) { + self.entryIds = entryIds + } + + func addEntryIds(from provider: FeedlyEntryIdentifierProviding) { + entryIds.formUnion(provider.entryIds) + } + + func addEntryIds(in articleIds: [String]) { + entryIds.formUnion(articleIds) + } +} diff --git a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift index 5db8b2c0c..8af6714c7 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift @@ -11,16 +11,20 @@ import Articles import RSParser struct FeedlyEntryParser { - var entry: FeedlyEntry + let entry: FeedlyEntry + + private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer() var id: String { return entry.id } - var feedUrl: String { + /// When ingesting articles, the feedURL must match a feed's `webFeedID` for the article to be reachable between it and its matching feed. It reminds me of a foreign key. + var feedUrl: String? { guard let id = entry.origin?.streamId else { - assertionFailure() - return "" + // At this point, check Feedly's API isn't glitching or the response has not changed structure. + assertionFailure("Entries need to be traceable to a feed or this entry will be dropped.") + return nil } return id } @@ -36,7 +40,7 @@ struct FeedlyEntryParser { } var title: String? { - return entry.title + return rightToLeftTextSantizer.sanitize(entry.title) } var contentHMTL: String? { @@ -49,7 +53,7 @@ struct FeedlyEntryParser { } var summary: String? { - return entry.summary?.content + return rightToLeftTextSantizer.sanitize(entry.summary?.content) } var datePublished: Date { @@ -67,6 +71,7 @@ struct FeedlyEntryParser { return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)]) } + /// While there is not yet a tagging interface, articles can still be searched for by tags. var tags: Set? { guard let labels = entry.tags?.compactMap({ $0.label }), !labels.isEmpty else { return nil @@ -82,7 +87,11 @@ struct FeedlyEntryParser { return attachments.isEmpty ? nil : Set(attachments) } - var parsedItemRepresentation: ParsedItem { + var parsedItemRepresentation: ParsedItem? { + guard let feedUrl = feedUrl else { + return nil + } + return ParsedItem(syncServiceID: id, uniqueID: id, // This value seems to get ignored or replaced. feedURL: feedUrl, diff --git a/Frameworks/Account/Feedly/Models/FeedlyFeed.swift b/Frameworks/Account/Feedly/Models/FeedlyFeed.swift index 438365110..eab1eb90a 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyFeed.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyFeed.swift @@ -9,8 +9,8 @@ import Foundation struct FeedlyFeed: Codable { - var id: String - var title: String? - var updated: Date? - var website: String? + let id: String + let title: String? + let updated: Date? + let website: String? } diff --git a/Frameworks/Account/Feedly/Models/FeedlyFeedParser.swift b/Frameworks/Account/Feedly/Models/FeedlyFeedParser.swift new file mode 100644 index 000000000..e2f288dce --- /dev/null +++ b/Frameworks/Account/Feedly/Models/FeedlyFeedParser.swift @@ -0,0 +1,32 @@ +// +// FeedlyFeedParser.swift +// Account +// +// Created by Kiel Gillard on 29/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedlyFeedParser { + let feed: FeedlyFeed + + private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer() + + var title: String? { + return rightToLeftTextSantizer.sanitize(feed.title) ?? "" + } + + var webFeedID: String { + return feed.id + } + + var url: String { + let resource = FeedlyFeedResourceId(id: feed.id) + return resource.url + } + + var homePageURL: String? { + return feed.website + } +} diff --git a/Frameworks/Account/Feedly/Models/FeedlyFeedsSearchResponse.swift b/Frameworks/Account/Feedly/Models/FeedlyFeedsSearchResponse.swift index 17437ac23..8ddbd563b 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyFeedsSearchResponse.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyFeedsSearchResponse.swift @@ -11,9 +11,9 @@ import Foundation struct FeedlyFeedsSearchResponse: Decodable { struct Feed: Decodable { - var title: String - var feedId: String + let title: String + let feedId: String } - var results: [Feed] + let results: [Feed] } diff --git a/Frameworks/Account/Feedly/Models/FeedlyLink.swift b/Frameworks/Account/Feedly/Models/FeedlyLink.swift index 3756e1034..879341a08 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyLink.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyLink.swift @@ -9,10 +9,10 @@ import Foundation struct FeedlyLink: Decodable { - var href: String + let href: String /// The mime type of the resource located by `href`. /// When `nil`, it's probably a web page? /// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ - var type: String? + let type: String? } diff --git a/Frameworks/Account/Feedly/Models/FeedlyOrigin.swift b/Frameworks/Account/Feedly/Models/FeedlyOrigin.swift index 3ecb6528e..bd4a8cc86 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyOrigin.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyOrigin.swift @@ -9,7 +9,7 @@ import Foundation struct FeedlyOrigin: Decodable { - var title: String? - var streamId: String - var htmlUrl: String + let title: String? + let streamId: String? + let htmlUrl: String? } diff --git a/Frameworks/Account/Feedly/Models/FeedlyRTLTextSanitizer.swift b/Frameworks/Account/Feedly/Models/FeedlyRTLTextSanitizer.swift new file mode 100644 index 000000000..11eb2dcb5 --- /dev/null +++ b/Frameworks/Account/Feedly/Models/FeedlyRTLTextSanitizer.swift @@ -0,0 +1,28 @@ +// +// FeedlyRTLTextSanitizer.swift +// Account +// +// Created by Kiel Gillard on 28/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedlyRTLTextSanitizer { + private let rightToLeftPrefix = "
" + private let rightToLeftSuffix = "
" + + func sanitize(_ sourceText: String?) -> String? { + guard let source = sourceText, !source.isEmpty else { + return sourceText + } + + guard source.hasPrefix(rightToLeftPrefix) && source.hasSuffix(rightToLeftSuffix) else { + return source + } + + let start = source.index(source.startIndex, offsetBy: rightToLeftPrefix.indices.count) + let end = source.index(source.endIndex, offsetBy: -rightToLeftSuffix.indices.count) + return String(source[start..) { - guard !isCancelled else { + guard !isCanceled else { didFinish() return } @@ -140,48 +146,12 @@ public final class OAuthAccountAuthorizationOperation: Operation, ASWebAuthentic private func didFinish() { assert(Thread.isMainThread) - assert(!isFinished, "Finished operation is attempting to finish again.") - self.isExecutingOperation = false - self.isFinishedOperation = true + operationDelegate?.operationDidComplete(self) } private func didFinish(_ error: Error) { assert(Thread.isMainThread) - assert(!isFinished, "Finished operation is attempting to finish again.") delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error) didFinish() } - - override public func start() { - isExecutingOperation = true - DispatchQueue.main.async { - self.main() - } - } - - override public var isExecuting: Bool { - return isExecutingOperation - } - - private var isExecutingOperation = false { - willSet { - willChangeValue(for: \.isExecuting) - } - didSet { - didChangeValue(for: \.isExecuting) - } - } - - override public var isFinished: Bool { - return isFinishedOperation - } - - private var isFinishedOperation = false { - willSet { - willChangeValue(for: \.isFinished) - } - didSet { - didChangeValue(for: \.isFinished) - } - } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift index 0aab065e3..76fdde08b 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift @@ -9,20 +9,20 @@ import Foundation import os.log import RSWeb +import RSCore class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate { - private let operationQueue: OperationQueue - + + private let operationQueue = MainThreadOperationQueue() var addCompletionHandler: ((Result) -> ())? - + init(account: Account, credentials: Credentials, resource: FeedlyFeedResourceId, service: FeedlyAddFeedToCollectionService, container: Container, progress: DownloadProgress, log: OSLog) throws { - let validator = FeedlyFeedContainerValidator(container: container, userId: credentials.username) + let validator = FeedlyFeedContainerValidator(container: container) let (folder, collectionId) = try validator.getValidContainer() - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true - + self.operationQueue.suspend() + super.init() self.downloadProgress = progress @@ -30,31 +30,28 @@ class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: resource, feedName: nil, collectionId: collectionId, service: service) addRequest.delegate = self addRequest.downloadProgress = progress - self.operationQueue.addOperation(addRequest) + self.operationQueue.add(addRequest) let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log) createFeeds.downloadProgress = progress createFeeds.addDependency(addRequest) - self.operationQueue.addOperation(createFeeds) + self.operationQueue.add(createFeeds) let finishOperation = FeedlyCheckpointOperation() finishOperation.checkpointDelegate = self finishOperation.downloadProgress = progress finishOperation.addDependency(createFeeds) - self.operationQueue.addOperation(finishOperation) + self.operationQueue.add(finishOperation) } - override func cancel() { + override func run() { + operationQueue.resume() + } + + override func didCancel() { operationQueue.cancelAllOperations() - super.cancel() - didFinish() - } - - override func main() { - guard !isCancelled else { - return - } - operationQueue.isSuspended = false + addCompletionHandler = nil + super.didCancel() } func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { @@ -65,7 +62,7 @@ class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, } func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) { - guard !isCancelled else { + guard !isCanceled else { return } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift index 1228fa1e8..ce749a83d 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyAddFeedToCollectionOperation.swift @@ -13,13 +13,14 @@ protocol FeedlyAddFeedToCollectionService { } final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding, FeedlyResourceProviding { + let feedName: String? let collectionId: String let service: FeedlyAddFeedToCollectionService let account: Account let folder: Folder let feedResource: FeedlyFeedResourceId - + init(account: Account, folder: Folder, feedResource: FeedlyFeedResourceId, feedName: String? = nil, collectionId: String, service: FeedlyAddFeedToCollectionService) { self.account = account self.folder = folder @@ -35,23 +36,23 @@ final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndF return feedResource } - override func main() { - guard !isCancelled else { - return didFinish() - } - + override func run() { service.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionId) { [weak self] result in guard let self = self else { return } - guard !self.isCancelled else { - return self.didFinish() + if self.isCanceled { + self.didFinish() + return } self.didCompleteRequest(result) } } - - private func didCompleteRequest(_ result: Result<[FeedlyFeed], Error>) { +} + +private extension FeedlyAddFeedToCollectionOperation { + + func didCompleteRequest(_ result: Result<[FeedlyFeed], Error>) { switch result { case .success(let feedlyFeeds): feedsAndFolders = [(feedlyFeeds, folder)] @@ -59,13 +60,13 @@ final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndF let feedsWithCreatedFeedId = feedlyFeeds.filter { $0.id == resource.id } if feedsWithCreatedFeedId.isEmpty { - didFinish(AccountError.createErrorNotFound) + didFinish(with: AccountError.createErrorNotFound) } else { didFinish() } case .failure(let error): - didFinish(error) + didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index bb98a7ada..e3641511e 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -8,75 +8,71 @@ import Foundation import os.log +import SyncDatabase import RSWeb +import RSCore class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate { - private let operationQueue: OperationQueue + + private let operationQueue = MainThreadOperationQueue() private let folder: Folder private let collectionId: String private let url: String private let account: Account private let credentials: Credentials + private let database: SyncDatabase private let feedName: String? private let addToCollectionService: FeedlyAddFeedToCollectionService private let syncUnreadIdsService: FeedlyGetStreamIdsService private let getStreamContentsService: FeedlyGetStreamContentsService private let log: OSLog - + private var feedResourceId: FeedlyFeedResourceId? var addCompletionHandler: ((Result) -> ())? - - init(account: Account, credentials: Credentials, url: String, feedName: String?, searchService: FeedlySearchService, addToCollectionService: FeedlyAddFeedToCollectionService, syncUnreadIdsService: FeedlyGetStreamIdsService, getStreamContentsService: FeedlyGetStreamContentsService, container: Container, progress: DownloadProgress, log: OSLog) throws { + + init(account: Account, credentials: Credentials, url: String, feedName: String?, searchService: FeedlySearchService, addToCollectionService: FeedlyAddFeedToCollectionService, syncUnreadIdsService: FeedlyGetStreamIdsService, getStreamContentsService: FeedlyGetStreamContentsService, database: SyncDatabase, container: Container, progress: DownloadProgress, log: OSLog) throws { - let validator = FeedlyFeedContainerValidator(container: container, userId: credentials.username) + + let validator = FeedlyFeedContainerValidator(container: container) (self.folder, self.collectionId) = try validator.getValidContainer() self.url = url - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true + self.operationQueue.suspend() self.account = account self.credentials = credentials + self.database = database self.feedName = feedName self.addToCollectionService = addToCollectionService self.syncUnreadIdsService = syncUnreadIdsService self.getStreamContentsService = getStreamContentsService self.log = log - + super.init() - + self.downloadProgress = progress let search = FeedlySearchOperation(query: url, locale: .current, service: searchService) search.delegate = self search.searchDelegate = self search.downloadProgress = progress - self.operationQueue.addOperation(search) + self.operationQueue.add(search) } - override func cancel() { + override func run() { + operationQueue.resume() + } + + override func didCancel() { operationQueue.cancelAllOperations() - super.cancel() - - didFinish() - - // Operation should silently cancel. addCompletionHandler = nil + super.didCancel() } - - override func main() { - guard !isCancelled else { - return - } - operationQueue.isSuspended = false - } - - private var feedResourceId: FeedlyFeedResourceId? - + func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse) { - guard !isCancelled else { + guard !isCanceled else { return } guard let first = response.results.first else { - return didFinish(AccountError.createErrorNotFound) + return didFinish(with: AccountError.createErrorNotFound) } let feedResourceId = FeedlyFeedResourceId(id: first.feedId) @@ -85,42 +81,47 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: feedResourceId, feedName: feedName, collectionId: collectionId, service: addToCollectionService) addRequest.delegate = self addRequest.downloadProgress = downloadProgress - self.operationQueue.addOperation(addRequest) + operationQueue.add(addRequest) let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log) + createFeeds.delegate = self createFeeds.addDependency(addRequest) createFeeds.downloadProgress = downloadProgress - self.operationQueue.addOperation(createFeeds) + operationQueue.add(createFeeds) - let syncUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: syncUnreadIdsService, newerThan: nil, log: log) + let syncUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: syncUnreadIdsService, database: database, newerThan: nil, log: log) syncUnread.addDependency(createFeeds) syncUnread.downloadProgress = downloadProgress - self.operationQueue.addOperation(syncUnread) + syncUnread.delegate = self + operationQueue.add(syncUnread) - let syncFeed = FeedlySyncStreamContentsOperation(account: account, resource: feedResourceId, service: getStreamContentsService, newerThan: nil, log: log) + let syncFeed = FeedlySyncStreamContentsOperation(account: account, resource: feedResourceId, service: getStreamContentsService, isPagingEnabled: false, newerThan: nil, log: log) syncFeed.addDependency(syncUnread) syncFeed.downloadProgress = downloadProgress - self.operationQueue.addOperation(syncFeed) + syncFeed.delegate = self + operationQueue.add(syncFeed) let finishOperation = FeedlyCheckpointOperation() finishOperation.checkpointDelegate = self finishOperation.downloadProgress = downloadProgress finishOperation.addDependency(syncFeed) - self.operationQueue.addOperation(finishOperation) + finishOperation.delegate = self + operationQueue.add(finishOperation) } func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { addCompletionHandler?(.failure(error)) addCompletionHandler = nil + os_log(.debug, log: log, "Unable to add new feed: %{public}@.", error as NSError) + cancel() } func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) { - guard !isCancelled else { + guard !isCanceled else { return } - defer { didFinish() } @@ -128,14 +129,12 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl guard let handler = addCompletionHandler else { return } - if let feedResource = feedResourceId, let feed = folder.existingWebFeed(withWebFeedID: feedResource.id) { handler(.success(feed)) - - } else { + } + else { handler(.failure(AccountError.createErrorNotFound)) } - addCompletionHandler = nil } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyCheckpointOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyCheckpointOperation.swift index 7f5b07110..d577abeae 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyCheckpointOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyCheckpointOperation.swift @@ -12,15 +12,14 @@ protocol FeedlyCheckpointOperationDelegate: class { func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) } -/// Single responsibility is to let the delegate know an instance is executing. The semantics are up to the delegate. +/// Let the delegate know an instance is executing. The semantics are up to the delegate. final class FeedlyCheckpointOperation: FeedlyOperation { weak var checkpointDelegate: FeedlyCheckpointOperationDelegate? - - override func main() { - defer { didFinish() } - guard !isCancelled else { - return + + override func run() { + defer { + didFinish() } checkpointDelegate?.feedlyCheckpointOperationDidReachCheckpoint(self) } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift index 990c05bcf..264c046a1 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift @@ -15,17 +15,17 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation { let account: Account let feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding let log: OSLog - + init(account: Account, feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding, log: OSLog) { self.feedsAndFoldersProvider = feedsAndFoldersProvider self.account = account self.log = log } - override func main() { - defer { didFinish() } - - guard !isCancelled else { return } + override func run() { + defer { + didFinish() + } let pairs = feedsAndFoldersProvider.feedsAndFolders @@ -68,9 +68,11 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation { } // no exsiting feed, create a new one - let id = collectionFeed.id - let url = FeedlyFeedResourceId(id: id).url - let feed = account.createWebFeed(with: collectionFeed.title, url: url, webFeedID: id, homePageURL: collectionFeed.website) + let parser = FeedlyFeedParser(feed: collectionFeed) + let feed = account.createWebFeed(with: parser.title, + url: parser.url, + webFeedID: parser.webFeedID, + homePageURL: parser.homePageURL) // So the same feed isn't created more than once. feedsAdded.insert(feed) diff --git a/Frameworks/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift new file mode 100644 index 000000000..550ee34d7 --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift @@ -0,0 +1,97 @@ +// +// FeedlyDownloadArticlesOperation.swift +// Account +// +// Created by Kiel Gillard on 9/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSCore +import RSWeb + +class FeedlyDownloadArticlesOperation: FeedlyOperation { + + private let account: Account + private let log: OSLog + private let missingArticleEntryIdProvider: FeedlyEntryIdentifierProviding + private let updatedArticleEntryIdProvider: FeedlyEntryIdentifierProviding + private let getEntriesService: FeedlyGetEntriesService + private let operationQueue = MainThreadOperationQueue() + private let finishOperation: FeedlyCheckpointOperation + + init(account: Account, missingArticleEntryIdProvider: FeedlyEntryIdentifierProviding, updatedArticleEntryIdProvider: FeedlyEntryIdentifierProviding, getEntriesService: FeedlyGetEntriesService, log: OSLog) { + self.account = account + self.operationQueue.suspend() + self.missingArticleEntryIdProvider = missingArticleEntryIdProvider + self.updatedArticleEntryIdProvider = updatedArticleEntryIdProvider + self.getEntriesService = getEntriesService + self.finishOperation = FeedlyCheckpointOperation() + self.log = log + super.init() + self.finishOperation.checkpointDelegate = self + self.operationQueue.add(self.finishOperation) + } + + override func run() { + var articleIds = missingArticleEntryIdProvider.entryIds + articleIds.formUnion(updatedArticleEntryIdProvider.entryIds) + + os_log(.debug, log: log, "Requesting %{public}i articles.", articleIds.count) + + let feedlyAPILimitBatchSize = 1000 + for articleIds in Array(articleIds).chunked(into: feedlyAPILimitBatchSize) { + + let provider = FeedlyEntryIdentifierProvider(entryIds: Set(articleIds)) + let getEntries = FeedlyGetEntriesOperation(account: account, service: getEntriesService, provider: provider, log: log) + getEntries.delegate = self + self.operationQueue.add(getEntries) + + let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, + parsedItemProvider: getEntries, + log: log) + organiseByFeed.delegate = self + organiseByFeed.addDependency(getEntries) + self.operationQueue.add(organiseByFeed) + + let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, + organisedItemsProvider: organiseByFeed, + log: log) + + updateAccount.delegate = self + updateAccount.addDependency(organiseByFeed) + self.operationQueue.add(updateAccount) + + finishOperation.addDependency(updateAccount) + } + + operationQueue.resume() + } + + override func didCancel() { + // TODO: fix error on below line: "Expression type '()' is ambiguous without more context" + //os_log(.debug, log: log, "Cancelling %{public}@.", self) + operationQueue.cancelAllOperations() + super.didCancel() + } +} + +extension FeedlyDownloadArticlesOperation: FeedlyCheckpointOperationDelegate { + + func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) { + didFinish() + } +} + +extension FeedlyDownloadArticlesOperation: FeedlyOperationDelegate { + + func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { + assert(Thread.isMainThread) + + // Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example. + os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError) + + cancel() + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyFetchIdsForMissingArticlesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyFetchIdsForMissingArticlesOperation.swift new file mode 100644 index 000000000..bc37dfdb7 --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyFetchIdsForMissingArticlesOperation.swift @@ -0,0 +1,36 @@ +// +// FeedlyFetchIdsForMissingArticlesOperation.swift +// Account +// +// Created by Kiel Gillard on 7/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log + +final class FeedlyFetchIdsForMissingArticlesOperation: FeedlyOperation, FeedlyEntryIdentifierProviding { + + private let account: Account + private let log: OSLog + + private(set) var entryIds = Set() + + init(account: Account, log: OSLog) { + self.account = account + self.log = log + } + + override func run() { + account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in + switch result { + case .success(let articleIds): + self.entryIds.formUnion(articleIds) + self.didFinish() + + case .failure(let error): + self.didFinish(with: error) + } + } + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetCollectionsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetCollectionsOperation.swift index da42a3659..297bd3cd7 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyGetCollectionsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetCollectionsOperation.swift @@ -13,25 +13,20 @@ protocol FeedlyCollectionProviding: class { var collections: [FeedlyCollection] { get } } -/// Single responsibility is to get Collections from Feedly. +/// Get Collections from Feedly. final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding { let service: FeedlyGetCollectionsService let log: OSLog private(set) var collections = [FeedlyCollection]() - + init(service: FeedlyGetCollectionsService, log: OSLog) { self.service = service self.log = log } - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { os_log(.debug, log: log, "Requesting collections.") service.getCollections { result in @@ -43,7 +38,7 @@ final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProv case .failure(let error): os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError) - self.didFinish(error) + self.didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetEntriesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetEntriesOperation.swift index e16cca103..f306792f6 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyGetEntriesOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetEntriesOperation.swift @@ -8,15 +8,17 @@ import Foundation import os.log +import RSParser + +/// Get full entries for the entry identifiers. +final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding { -/// Single responsibility is to get full entries for the entry identifiers. -final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding { let account: Account let service: FeedlyGetEntriesService - let provider: FeedlyEntryIdenifierProviding + let provider: FeedlyEntryIdentifierProviding let log: OSLog - - init(account: Account, service: FeedlyGetEntriesService, provider: FeedlyEntryIdenifierProviding, log: OSLog) { + + init(account: Account, service: FeedlyGetEntriesService, provider: FeedlyEntryIdentifierProviding, log: OSLog) { self.account = account self.service = service self.provider = provider @@ -25,12 +27,35 @@ final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding { private (set) var entries = [FeedlyEntry]() - override func main() { - guard !isCancelled else { - didFinish() - return + private var storedParsedEntries: Set? + + var parsedEntries: Set { + if let entries = storedParsedEntries { + return entries } + let parsed = Set(entries.compactMap { + FeedlyEntryParser(entry: $0).parsedItemRepresentation + }) + + // TODO: Fix the below. There’s an error on the os.log line: "Expression type '()' is ambiguous without more context" +// if parsed.count != entries.count { +// let entryIds = Set(entries.map { $0.id }) +// let parsedIds = Set(parsed.map { $0.uniqueID }) +// let difference = entryIds.subtracting(parsedIds) +// os_log(.debug, log: log, "%{public}@ dropping articles with ids: %{public}@.", self, difference) +// } + + storedParsedEntries = parsed + + return parsed + } + + var parsedItemProviderName: String { + return name ?? String(describing: Self.self) + } + + override func run() { service.getEntries(for: provider.entryIds) { result in switch result { case .success(let entries): @@ -39,7 +64,7 @@ final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding { case .failure(let error): os_log(.debug, log: self.log, "Unable to get entries: %{public}@.", error as NSError) - self.didFinish(error) + self.didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetStreamContentsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetStreamContentsOperation.swift index 40bb6080b..4c67bcfd4 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyGetStreamContentsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetStreamContentsOperation.swift @@ -15,7 +15,7 @@ protocol FeedlyEntryProviding { } protocol FeedlyParsedItemProviding { - var resource: FeedlyResourceId { get } + var parsedItemProviderName: String { get } var parsedEntries: Set { get } } @@ -23,7 +23,7 @@ protocol FeedlyGetStreamContentsOperationDelegate: class { func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) } -/// Single responsibility is to get the stream content of a Collection from Feedly. +/// Get the stream content of a Collection from Feedly. final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding { struct ResourceProvider: FeedlyResourceProviding { @@ -32,13 +32,13 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid let resourceProvider: FeedlyResourceProviding - var resource: FeedlyResourceId { - return resourceProvider.resource + var parsedItemProviderName: String { + return resourceProvider.resource.id } var entries: [FeedlyEntry] { guard let entries = stream?.items else { - assert(isFinished, "This should only be called when the operation finishes without error.") +// assert(isFinished, "This should only be called when the operation finishes without error.") assertionFailure("Has this operation been addeded as a dependency on the caller?") return [] } @@ -50,7 +50,17 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid return entries } - let parsed = Set(entries.map { FeedlyEntryParser(entry: $0).parsedItemRepresentation }) + let parsed = Set(entries.compactMap { + FeedlyEntryParser(entry: $0).parsedItemRepresentation + }) + + if parsed.count != entries.count { + let entryIds = Set(entries.map { $0.id }) + let parsedIds = Set(parsed.map { $0.uniqueID }) + let difference = entryIds.subtracting(parsedIds) + os_log(.debug, log: log, "Dropping articles with ids: %{public}@.", difference) + } + storedParsedEntries = parsed return parsed @@ -72,7 +82,7 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid let log: OSLog weak var streamDelegate: FeedlyGetStreamContentsOperationDelegate? - + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) { self.account = account self.resourceProvider = ResourceProvider(resource: resource) @@ -87,12 +97,7 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid self.init(account: account, resource: resourceProvider.resource, service: service, newerThan: newerThan, unreadOnly: unreadOnly, log: log) } - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in switch result { case .success(let stream): @@ -104,7 +109,7 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid case .failure(let error): os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError) - self.didFinish(error) + self.didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetStreamIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetStreamIdsOperation.swift index 6624a792b..4f6fea202 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyGetStreamIdsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetStreamIdsOperation.swift @@ -9,21 +9,15 @@ import Foundation import os.log -protocol FeedlyEntryIdenifierProviding: class { - var resource: FeedlyResourceId { get } - var entryIds: Set { get } -} - protocol FeedlyGetStreamIdsOperationDelegate: class { func feedlyGetStreamIdsOperation(_ operation: FeedlyGetStreamIdsOperation, didGet streamIds: FeedlyStreamIds) } /// Single responsibility is to get the stream ids from Feedly. -final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdenifierProviding, FeedlyUnreadEntryIdProviding { +final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding { var entryIds: Set { guard let ids = streamIds?.ids else { - assert(isFinished, "This should only be called when the operation finishes without error.") assertionFailure("Has this operation been addeded as a dependency on the caller?") return [] } @@ -39,7 +33,7 @@ final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdenifierPr let unreadOnly: Bool? let newerThan: Date? let log: OSLog - + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, continuation: String? = nil, newerThan: Date? = nil, unreadOnly: Bool?, log: OSLog) { self.account = account self.resource = resource @@ -52,12 +46,7 @@ final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdenifierPr weak var streamIdsDelegate: FeedlyGetStreamIdsOperationDelegate? - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { service.getStreamIds(for: resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in switch result { case .success(let stream): @@ -69,7 +58,7 @@ final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdenifierPr case .failure(let error): os_log(.debug, log: self.log, "Unable to get stream ids: %{public}@.", error as NSError) - self.didFinish(error) + self.didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift new file mode 100644 index 000000000..1ed7a2c10 --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift @@ -0,0 +1,79 @@ +// +// FeedlyGetUpdatedArticleIdsOperation.swift +// Account +// +// Created by Kiel Gillard on 11/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log + +/// Single responsibility is to identify articles that have changed since a particular date. +/// +/// Typically, it pages through the article ids of the global.all stream. +/// When all the article ids are collected, it is the responsibility of another operation to download them when appropriate. +class FeedlyGetUpdatedArticleIdsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding { + + private let account: Account + private let resource: FeedlyResourceId + private let service: FeedlyGetStreamIdsService + private let newerThan: Date? + private let log: OSLog + + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) { + self.account = account + self.resource = resource + self.service = service + self.newerThan = newerThan + self.log = log + } + + convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) { + let all = FeedlyCategoryResourceId.Global.all(for: credentials.username) + self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log) + } + + var entryIds: Set { + return storedUpdatedArticleIds + } + + private var storedUpdatedArticleIds = Set() + + override func run() { + getStreamIds(nil) + } + + private func getStreamIds(_ continuation: String?) { + guard let date = newerThan else { + os_log(.debug, log: log, "No date provided so everything must be new (nothing is updated).") + didFinish() + return + } + + service.getStreamIds(for: resource, continuation: continuation, newerThan: date, unreadOnly: nil, completion: didGetStreamIds(_:)) + } + + private func didGetStreamIds(_ result: Result) { + guard !isCanceled else { + didFinish() + return + } + + switch result { + case .success(let streamIds): + storedUpdatedArticleIds.formUnion(streamIds.ids) + + guard let continuation = streamIds.continuation else { + os_log(.debug, log: log, "%{public}i articles updated since last successful sync start date.", storedUpdatedArticleIds.count) + didFinish() + return + } + + getStreamIds(continuation) + + case .failure(let error): + didFinish(with: error) + } + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift new file mode 100644 index 000000000..02dc42043 --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift @@ -0,0 +1,148 @@ +// +// FeedlyIngestStarredArticleIdsOperation.swift +// Account +// +// Created by Kiel Gillard on 15/10/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import SyncDatabase + +/// Clone locally the remote starred article state. +/// +/// Typically, it pages through the article ids of the global.saved stream. +/// When all the article ids are collected, a status is created for each. +/// The article ids previously marked as starred but not collected become unstarred. +/// So this operation has side effects *for the entire account* it operates on. +final class FeedlyIngestStarredArticleIdsOperation: FeedlyOperation { + + private let account: Account + private let resource: FeedlyResourceId + private let service: FeedlyGetStreamIdsService + private let database: SyncDatabase + private var remoteEntryIds = Set() + private let log: OSLog + + convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) { + let resource = FeedlyTagResourceId.Global.saved(for: credentials.username) + self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log) + } + + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) { + self.account = account + self.resource = resource + self.service = service + self.database = database + self.log = log + } + + override func run() { + getStreamIds(nil) + } + + private func getStreamIds(_ continuation: String?) { + service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil, completion: didGetStreamIds(_:)) + } + + private func didGetStreamIds(_ result: Result) { + guard !isCanceled else { + didFinish() + return + } + + switch result { + case .success(let streamIds): + + remoteEntryIds.formUnion(streamIds.ids) + + guard let continuation = streamIds.continuation else { + removeEntryIdsWithPendingStatus() + return + } + + getStreamIds(continuation) + + case .failure(let error): + didFinish(with: error) + } + } + + /// Do not override pending statuses with the remote statuses of the same articles, otherwise an article will temporarily re-acquire the remote status before the pending status is pushed and subseqently pulled. + private func removeEntryIdsWithPendingStatus() { + guard !isCanceled else { + didFinish() + return + } + + database.selectPendingStarredStatusArticleIDs { result in + switch result { + case .success(let pendingArticleIds): + self.remoteEntryIds.subtract(pendingArticleIds) + + self.updateStarredStatuses() + + case .failure(let error): + self.didFinish(with: error) + } + } + } + + private func updateStarredStatuses() { + guard !isCanceled else { + didFinish() + return + } + + account.fetchStarredArticleIDs { result in + switch result { + case .success(let localStarredArticleIDs): + self.processStarredArticleIDs(localStarredArticleIDs) + + case .failure(let error): + self.didFinish(with: error) + } + } + } + + func processStarredArticleIDs(_ localStarredArticleIDs: Set) { + guard !isCanceled else { + didFinish() + return + } + + let remoteStarredArticleIDs = remoteEntryIds + + let group = DispatchGroup() + + final class StarredStatusResults { + var markAsStarredError: Error? + var markAsUnstarredError: Error? + } + + let results = StarredStatusResults() + + group.enter() + account.markAsStarred(remoteStarredArticleIDs) { error in + results.markAsStarredError = error + group.leave() + } + + let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs) + group.enter() + account.markAsUnstarred(deltaUnstarredArticleIDs) { error in + results.markAsUnstarredError = error + group.leave() + } + + group.notify(queue: .main) { + let markingError = results.markAsStarredError ?? results.markAsUnstarredError + guard let error = markingError else { + self.didFinish() + return + } + self.didFinish(with: error) + } + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift new file mode 100644 index 000000000..12b906d12 --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift @@ -0,0 +1,71 @@ +// +// FeedlyIngestStreamArticleIdsOperation.swift +// Account +// +// Created by Kiel Gillard on 9/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log + +/// Ensure a status exists for every article id the user might be interested in. +/// +/// Typically, it pages through the article ids of the global.all stream. +/// As the article ids are collected, a default read status is created for each. +/// So this operation has side effects *for the entire account* it operates on. +class FeedlyIngestStreamArticleIdsOperation: FeedlyOperation { + + private let account: Account + private let resource: FeedlyResourceId + private let service: FeedlyGetStreamIdsService + private let log: OSLog + + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, log: OSLog) { + self.account = account + self.resource = resource + self.service = service + self.log = log + } + + convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, log: OSLog) { + let all = FeedlyCategoryResourceId.Global.all(for: credentials.username) + self.init(account: account, resource: all, service: service, log: log) + } + + override func run() { + getStreamIds(nil) + } + + private func getStreamIds(_ continuation: String?) { + service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil, completion: didGetStreamIds(_:)) + } + + private func didGetStreamIds(_ result: Result) { + guard !isCanceled else { + didFinish() + return + } + + switch result { + case .success(let streamIds): + account.createStatusesIfNeeded(articleIDs: Set(streamIds.ids)) { databaseError in + + if let error = databaseError { + self.didFinish(with: error) + return + } + + guard let continuation = streamIds.continuation else { + os_log(.debug, log: self.log, "Reached end of stream for %@", self.resource.id) + self.didFinish() + return + } + + self.getStreamIds(continuation) + } + case .failure(let error): + didFinish(with: error) + } + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift new file mode 100644 index 000000000..669a9672d --- /dev/null +++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift @@ -0,0 +1,148 @@ +// +// FeedlyIngestUnreadArticleIdsOperation.swift +// Account +// +// Created by Kiel Gillard on 18/10/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSParser +import SyncDatabase + +/// Clone locally the remote unread article state. +/// +/// Typically, it pages through the unread article ids of the global.all stream. +/// When all the unread article ids are collected, a status is created for each. +/// The article ids previously marked as unread but not collected become read. +/// So this operation has side effects *for the entire account* it operates on. +final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation { + + private let account: Account + private let resource: FeedlyResourceId + private let service: FeedlyGetStreamIdsService + private let database: SyncDatabase + private var remoteEntryIds = Set() + private let log: OSLog + + convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) { + let resource = FeedlyCategoryResourceId.Global.all(for: credentials.username) + self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log) + } + + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) { + self.account = account + self.resource = resource + self.service = service + self.database = database + self.log = log + } + + override func run() { + getStreamIds(nil) + } + + private func getStreamIds(_ continuation: String?) { + service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: true, completion: didGetStreamIds(_:)) + } + + private func didGetStreamIds(_ result: Result) { + guard !isCanceled else { + didFinish() + return + } + + switch result { + case .success(let streamIds): + + remoteEntryIds.formUnion(streamIds.ids) + + guard let continuation = streamIds.continuation else { + removeEntryIdsWithPendingStatus() + return + } + + getStreamIds(continuation) + + case .failure(let error): + didFinish(with: error) + } + } + + /// Do not override pending statuses with the remote statuses of the same articles, otherwise an article will temporarily re-acquire the remote status before the pending status is pushed and subseqently pulled. + private func removeEntryIdsWithPendingStatus() { + guard !isCanceled else { + didFinish() + return + } + + database.selectPendingReadStatusArticleIDs { result in + switch result { + case .success(let pendingArticleIds): + self.remoteEntryIds.subtract(pendingArticleIds) + + self.updateUnreadStatuses() + + case .failure(let error): + self.didFinish(with: error) + } + } + } + + private func updateUnreadStatuses() { + guard !isCanceled else { + didFinish() + return + } + + account.fetchUnreadArticleIDs { result in + switch result { + case .success(let localUnreadArticleIDs): + self.processUnreadArticleIDs(localUnreadArticleIDs) + + case .failure(let error): + self.didFinish(with: error) + } + } + } + + private func processUnreadArticleIDs(_ localUnreadArticleIDs: Set) { + guard !isCanceled else { + didFinish() + return + } + + let remoteUnreadArticleIDs = remoteEntryIds + let group = DispatchGroup() + + final class ReadStatusResults { + var markAsUnreadError: Error? + var markAsReadError: Error? + } + + let results = ReadStatusResults() + + group.enter() + account.markAsUnread(remoteUnreadArticleIDs) { error in + results.markAsUnreadError = error + group.leave() + } + + let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs) + group.enter() + account.markAsRead(articleIDsToMarkRead) { error in + results.markAsReadError = error + group.leave() + } + + group.notify(queue: .main) { + let markingError = results.markAsReadError ?? results.markAsUnreadError + guard let error = markingError else { + self.didFinish() + return + } + self.didFinish(with: error) + } + } +} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyLogoutOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyLogoutOperation.swift index 80e607c18..29fb7eed6 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyLogoutOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyLogoutOperation.swift @@ -14,6 +14,7 @@ protocol FeedlyLogoutService { } final class FeedlyLogoutOperation: FeedlyOperation { + let service: FeedlyLogoutService let account: Account let log: OSLog @@ -24,11 +25,7 @@ final class FeedlyLogoutOperation: FeedlyOperation { self.log = log } - override func main() { - guard !isCancelled else { - didFinish() - return - } + override func run() { os_log("Requesting logout of %{public}@ account.", "\(account.type)") service.logout(completion: didCompleteLogout(_:)) } @@ -48,7 +45,7 @@ final class FeedlyLogoutOperation: FeedlyOperation { case .failure(let error): os_log("Logout failed because %{public}@.", error as NSError) - didFinish(error) + didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyMirrorCollectionsAsFoldersOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyMirrorCollectionsAsFoldersOperation.swift index d820b6c88..376459ff6 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyMirrorCollectionsAsFoldersOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyMirrorCollectionsAsFoldersOperation.swift @@ -9,61 +9,55 @@ import Foundation import os.log -protocol FeedlyCollectionsAndFoldersProviding: class { - var collectionsAndFolders: [(FeedlyCollection, Folder)] { get } -} - protocol FeedlyFeedsAndFoldersProviding { var feedsAndFolders: [([FeedlyFeed], Folder)] { get } } -/// Single responsibility is accurately reflect Collections from Feedly as Folders. -final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCollectionsAndFoldersProviding, FeedlyFeedsAndFoldersProviding { +/// Reflect Collections from Feedly as Folders. +final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding { let account: Account let collectionsProvider: FeedlyCollectionProviding let log: OSLog - private(set) var collectionsAndFolders = [(FeedlyCollection, Folder)]() private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]() - + init(account: Account, collectionsProvider: FeedlyCollectionProviding, log: OSLog) { self.collectionsProvider = collectionsProvider self.account = account self.log = log } - override func main() { - defer { didFinish() } - - guard !isCancelled else { return } + override func run() { + defer { + didFinish() + } let localFolders = account.folders ?? Set() let collections = collectionsProvider.collections - let pairs = collections.compactMap { collection -> (FeedlyCollection, Folder)? in - guard let folder = account.ensureFolder(with: collection.label) else { + feedsAndFolders = collections.compactMap { collection -> ([FeedlyFeed], Folder)? in + let parser = FeedlyCollectionParser(collection: collection) + guard let folder = account.ensureFolder(with: parser.folderName) else { assertionFailure("Why wasn't a folder created?") return nil } - folder.externalID = collection.id - return (collection, folder) - } - - collectionsAndFolders = pairs - os_log(.debug, log: log, "Ensured %i folders for %i collections.", pairs.count, collections.count) - - feedsAndFolders = pairs.map { (collection, folder) -> (([FeedlyFeed], Folder)) in + folder.externalID = parser.externalID return (collection.feeds, folder) } - // Remove folders without a corresponding collection - let collectionFolders = Set(pairs.map { $0.1 }) - let foldersWithoutCollections = localFolders.subtracting(collectionFolders) - for unmatched in foldersWithoutCollections { - account.removeFolder(unmatched) - } + os_log(.debug, log: log, "Ensured %i folders for %i collections.", feedsAndFolders.count, collections.count) - os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay }) + // Remove folders without a corresponding collection + let collectionFolders = Set(feedsAndFolders.map { $0.1 }) + let foldersWithoutCollections = localFolders.subtracting(collectionFolders) + + if !foldersWithoutCollections.isEmpty { + for unmatched in foldersWithoutCollections { + account.removeFolder(unmatched) + } + + os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay }) + } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyOperation.swift index f1ea40369..e25ae63b0 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyOperation.swift @@ -8,97 +8,55 @@ import Foundation import RSWeb +import RSCore protocol FeedlyOperationDelegate: class { func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) } -/// Abstract class common to all the tasks required to ingest content from Feedly into NetNewsWire. -/// Each task should try to have a single responsibility so they can be easily composed with others. -class FeedlyOperation: Operation { - +/// Abstract base class for Feedly sync operations. +/// +/// Normally we don’t do inheritance — but in this case +/// it’s the best option. +class FeedlyOperation: MainThreadOperation { + weak var delegate: FeedlyOperationDelegate? - var downloadProgress: DownloadProgress? { didSet { - guard downloadProgress == nil || !isExecuting else { - fatalError("\(\FeedlyOperation.downloadProgress) was set to late. Set before operation starts executing.") - } oldValue?.completeTask() downloadProgress?.addToNumberOfTasksAndRemaining(1) } } - - func didFinish() { - assert(Thread.isMainThread) - assert(!isFinished, "Finished operation is attempting to finish again.") - - downloadProgress = nil - - isExecutingOperation = false - isFinishedOperation = true + + // MainThreadOperation + var isCanceled = false { + didSet { + if isCanceled { + didCancel() + } + } } - - func didFinish(_ error: Error) { - assert(Thread.isMainThread) - assert(!isFinished, "Finished operation is attempting to finish again.") + var id: Int? + weak var operationDelegate: MainThreadOperationDelegate? + var name: String? + var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + func run() { + } + + func didFinish() { + if !isCanceled { + operationDelegate?.operationDidComplete(self) + } + downloadProgress?.completeTask() + } + + func didFinish(with error: Error) { delegate?.feedlyOperation(self, didFailWith: error) didFinish() } - - override func cancel() { - // If the operation never started, disown the download progress. - if !isExecuting && !isFinished, downloadProgress != nil { - DispatchQueue.main.async { - self.downloadProgress = nil - } - } - super.cancel() - } - - override func start() { - guard !isCancelled else { - isExecutingOperation = false - isFinishedOperation = true - - if downloadProgress != nil { - DispatchQueue.main.async { - self.downloadProgress = nil - } - } - - return - } - - isExecutingOperation = true - DispatchQueue.main.async { - self.main() - } - } - - override var isExecuting: Bool { - return isExecutingOperation - } - - private var isExecutingOperation = false { - willSet { - willChangeValue(for: \.isExecuting) - } - didSet { - didChangeValue(for: \.isExecuting) - } - } - - override var isFinished: Bool { - return isFinishedOperation - } - - private var isFinishedOperation = false { - willSet { - willChangeValue(for: \.isFinished) - } - didSet { - didChangeValue(for: \.isFinished) - } + + func didCancel() { + didFinish() } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyOrganiseParsedItemsByFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyOrganiseParsedItemsByFeedOperation.swift index b535c1b44..a55ea1839 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyOrganiseParsedItemsByFeedOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyOrganiseParsedItemsByFeedOperation.swift @@ -11,23 +11,24 @@ import RSParser import os.log protocol FeedlyParsedItemsByFeedProviding { - var providerName: String { get } + var parsedItemsByFeedProviderName: String { get } var parsedItemsKeyedByFeedId: [String: Set] { get } } -/// Single responsibility is to group articles by their feeds. +/// Group articles by their feeds. final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding { + private let account: Account private let parsedItemProvider: FeedlyParsedItemProviding private let log: OSLog - var parsedItemsKeyedByFeedId: [String : Set] { - assert(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type. - return itemsKeyedByFeedId + var parsedItemsByFeedProviderName: String { + return name ?? String(describing: Self.self) } - var providerName: String { - return parsedItemProvider.resource.id + var parsedItemsKeyedByFeedId: [String : Set] { + precondition(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type. + return itemsKeyedByFeedId } private var itemsKeyedByFeedId = [String: Set]() @@ -38,11 +39,11 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar self.log = log } - override func main() { - defer { didFinish() } - - guard !isCancelled else { return } - + override func run() { + defer { + didFinish() + } + let items = parsedItemProvider.parsedEntries var dict = [String: Set](minimumCapacity: items.count) @@ -57,11 +58,9 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar } }() dict[key] = value - - guard !isCancelled else { return } } - os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.resource.id) + os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.parsedItemProviderName) itemsKeyedByFeedId = dict } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift index 2ac2f3a0d..d1c68d970 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift @@ -11,6 +11,7 @@ import os.log import RSWeb final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { + let service: OAuthAccessTokenRefreshing let oauthClient: OAuthAuthorizationClient let account: Account @@ -23,12 +24,7 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { self.log = log } - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { let refreshToken: Credentials do { @@ -40,7 +36,7 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { refreshToken = credentials } catch { - didFinish(error) + didFinish(with: error) return } @@ -70,11 +66,11 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { didFinish() } catch { - didFinish(error) + didFinish(with: error) } case .failure(let error): - didFinish(error) + didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRequestStreamsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRequestStreamsOperation.swift index addf7ad16..0cb7731ef 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyRequestStreamsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyRequestStreamsOperation.swift @@ -13,7 +13,7 @@ protocol FeedlyRequestStreamsOperationDelegate: class { func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetStreamContentsOperation) } -/// Single responsibility is to create one stream request operation for one Feedly collection. +/// Create one stream request operation for one Feedly collection. /// This is the start of the process of refreshing the entire contents of a Folder. final class FeedlyRequestStreamsOperation: FeedlyOperation { @@ -25,7 +25,7 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation { let log: OSLog let newerThan: Date? let unreadOnly: Bool? - + init(account: Account, collectionsProvider: FeedlyCollectionProviding, newerThan: Date?, unreadOnly: Bool?, service: FeedlyGetStreamContentsService, log: OSLog) { self.account = account self.service = service @@ -35,10 +35,10 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation { self.log = log } - override func main() { - defer { didFinish() } - - guard !isCancelled else { return } + override func run() { + defer { + didFinish() + } assert(queueDelegate != nil, "This is not particularly useful unless the `queueDelegate` is non-nil.") diff --git a/Frameworks/Account/Feedly/Operations/FeedlySearchOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySearchOperation.swift index 3251a9c38..5e9bfde85 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySearchOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySearchOperation.swift @@ -16,27 +16,22 @@ protocol FeedlySearchOperationDelegate: class { func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse) } -/// Single responsibility is to find one and only one feed for a given query (usually, a URL). +/// Find one and only one feed for a given query (usually, a URL). /// What happens when a feed is found for the URL is delegated to the `searchDelegate`. class FeedlySearchOperation: FeedlyOperation { + let query: String let locale: Locale let searchService: FeedlySearchService - weak var searchDelegate: FeedlySearchOperationDelegate? - + init(query: String, locale: Locale = .current, service: FeedlySearchService) { self.query = query self.locale = locale self.searchService = service } - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { searchService.getFeeds(for: query, count: 1, locale: locale.identifier) { result in switch result { case .success(let response): @@ -45,7 +40,7 @@ class FeedlySearchOperation: FeedlyOperation { self.didFinish() case .failure(let error): - self.didFinish(error) + self.didFinish(with: error) } } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlySendArticleStatusesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySendArticleStatusesOperation.swift index 0a20967e5..e01e6d4c7 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySendArticleStatusesOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySendArticleStatusesOperation.swift @@ -11,27 +11,29 @@ import Articles import SyncDatabase import os.log -/// Single responsibility is to take changes to statuses of articles locally and apply them to the corresponding the articles remotely. + +/// Take changes to statuses of articles locally and apply them to the corresponding the articles remotely. final class FeedlySendArticleStatusesOperation: FeedlyOperation { + private let database: SyncDatabase private let log: OSLog private let service: FeedlyMarkArticlesService - + init(database: SyncDatabase, service: FeedlyMarkArticlesService, log: OSLog) { self.database = database self.service = service self.log = log } - override func main() { - guard !isCancelled else { - didFinish() - return - } - + override func run() { os_log(.debug, log: log, "Sending article statuses...") database.selectForProcessing { result in + if self.isCanceled { + self.didFinish() + return + } + switch result { case .success(let syncStatuses): self.processStatuses(syncStatuses) diff --git a/Frameworks/Account/Feedly/Operations/FeedlySetStarredArticlesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySetStarredArticlesOperation.swift deleted file mode 100644 index e3b0623be..000000000 --- a/Frameworks/Account/Feedly/Operations/FeedlySetStarredArticlesOperation.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// FeedlySetStarredArticlesOperation.swift -// Account -// -// Created by Kiel Gillard on 14/10/19. -// Copyright © 2019 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import os.log - -protocol FeedlyStarredEntryIdProviding { - var entryIds: Set { get } -} - -/// Single responsibility is to associate a starred status for ingested and remote -/// articles identfied by the provided identifiers *for the entire account.* -final class FeedlySetStarredArticlesOperation: FeedlyOperation { - private let account: Account - private let allStarredEntryIdsProvider: FeedlyStarredEntryIdProviding - private let log: OSLog - - init(account: Account, allStarredEntryIdsProvider: FeedlyStarredEntryIdProviding, log: OSLog) { - self.account = account - self.allStarredEntryIdsProvider = allStarredEntryIdsProvider - self.log = log - } - - override func main() { - guard !isCancelled else { - didFinish() - return - } - - account.fetchStarredArticleIDs { result in - switch result { - case .success(let localStarredArticleIDs): - self.processStarredArticleIDs(localStarredArticleIDs) - - case .failure(let error): - self.didFinish(error) - } - } - } -} - -private extension FeedlySetStarredArticlesOperation { - - func processStarredArticleIDs(_ localStarredArticleIDs: Set) { - guard !isCancelled else { - didFinish() - return - } - - let remoteStarredArticleIDs = allStarredEntryIdsProvider.entryIds - guard !remoteStarredArticleIDs.isEmpty else { - didFinish() - return - } - - let group = DispatchGroup() - - final class StarredStatusResults { - var markAsStarredError: Error? - var markAsUnstarredError: Error? - } - - let results = StarredStatusResults() - - group.enter() - account.markAsStarred(remoteStarredArticleIDs) { error in - results.markAsStarredError = error - group.leave() - } - - let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs) - group.enter() - account.markAsUnstarred(deltaUnstarredArticleIDs) { error in - results.markAsUnstarredError = error - group.leave() - } - - group.notify(queue: .main) { - let markingError = results.markAsStarredError ?? results.markAsUnstarredError - guard let error = markingError else { - self.didFinish() - return - } - self.didFinish(error) - } - } -} diff --git a/Frameworks/Account/Feedly/Operations/FeedlySetUnreadArticlesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySetUnreadArticlesOperation.swift deleted file mode 100644 index f98e93e81..000000000 --- a/Frameworks/Account/Feedly/Operations/FeedlySetUnreadArticlesOperation.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// FeedlySetUnreadArticlesOperation.swift -// Account -// -// Created by Kiel Gillard on 25/9/19. -// Copyright © 2019 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import os.log - -protocol FeedlyUnreadEntryIdProviding { - var entryIds: Set { get } -} - -/// Single responsibility is to associate a read status for ingested and remote articles -/// where the provided article identifers identify the unread articles *for the entire account.* -final class FeedlySetUnreadArticlesOperation: FeedlyOperation { - private let account: Account - private let allUnreadIdsProvider: FeedlyUnreadEntryIdProviding - private let log: OSLog - - init(account: Account, allUnreadIdsProvider: FeedlyUnreadEntryIdProviding, log: OSLog) { - self.account = account - self.allUnreadIdsProvider = allUnreadIdsProvider - self.log = log - } - - override func main() { - guard !isCancelled else { - didFinish() - return - } - - account.fetchUnreadArticleIDs { result in - switch result { - case .success(let localUnreadArticleIDs): - self.processUnreadArticleIDs(localUnreadArticleIDs) - - case .failure(let error): - self.didFinish(error) - } - } - } -} - -private extension FeedlySetUnreadArticlesOperation { - - private func processUnreadArticleIDs(_ localUnreadArticleIDs: Set) { - guard !isCancelled else { - didFinish() - return - } - - let remoteUnreadArticleIDs = allUnreadIdsProvider.entryIds - guard !remoteUnreadArticleIDs.isEmpty else { - didFinish() - return - } - - let group = DispatchGroup() - - final class ReadStatusResults { - var markAsUnreadError: Error? - var markAsReadError: Error? - } - - let results = ReadStatusResults() - - group.enter() - account.markAsUnread(remoteUnreadArticleIDs) { error in - results.markAsUnreadError = error - group.leave() - } - - let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs) - group.enter() - account.markAsRead(articleIDsToMarkRead) { error in - results.markAsReadError = error - group.leave() - } - - group.notify(queue: .main) { - let markingError = results.markAsReadError ?? results.markAsUnreadError - guard let error = markingError else { - self.didFinish() - return - } - self.didFinish(error) - } - } -} diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift index 595ad0555..b0c87e027 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift @@ -10,20 +10,33 @@ import Foundation import os.log import SyncDatabase import RSWeb +import RSCore -/// Single responsibility is to compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past. +/// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past. final class FeedlySyncAllOperation: FeedlyOperation { - private let operationQueue: OperationQueue + + private let operationQueue = MainThreadOperationQueue() private let log: OSLog let syncUUID: UUID var syncCompletionHandler: ((Result) -> ())? - init(account: Account, credentials: Credentials, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredArticlesService: FeedlyGetStreamContentsService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) { + /// These requests to Feedly determine which articles to download: + /// 1. The set of all article ids we might need or show. + /// 2. The set of all unread article ids we might need or show (a subset of 1). + /// 3. The set of all article ids changed since the last sync (a subset of 1). + /// 4. The set of all starred article ids. + /// + /// On the response for 1, create statuses for each article id. + /// On the response for 2, create unread statuses for each article id and mark as read those no longer in the response. + /// On the response for 4, create starred statuses for each article id and mark as unstarred those no longer in the response. + /// + /// Download articles for statuses at the union of those statuses without its corresponding article and those included in 3 (changed since last successful sync). + /// + init(account: Account, credentials: Credentials, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredService: FeedlyGetStreamIdsService, getStreamIdsService: FeedlyGetStreamIdsService, getEntriesService: FeedlyGetEntriesService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) { self.syncUUID = UUID() self.log = log - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true + self.operationQueue.suspend() super.init() @@ -33,91 +46,99 @@ final class FeedlySyncAllOperation: FeedlyOperation { let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, service: markArticlesService, log: log) sendArticleStatuses.delegate = self sendArticleStatuses.downloadProgress = downloadProgress - self.operationQueue.addOperation(sendArticleStatuses) + self.operationQueue.add(sendArticleStatuses) // Get all the Collections the user has. let getCollections = FeedlyGetCollectionsOperation(service: getCollectionsService, log: log) getCollections.delegate = self getCollections.downloadProgress = downloadProgress getCollections.addDependency(sendArticleStatuses) - self.operationQueue.addOperation(getCollections) + self.operationQueue.add(getCollections) // Ensure a folder exists for each Collection, removing Folders without a corresponding Collection. let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: log) mirrorCollectionsAsFolders.delegate = self mirrorCollectionsAsFolders.addDependency(getCollections) - self.operationQueue.addOperation(mirrorCollectionsAsFolders) + self.operationQueue.add(mirrorCollectionsAsFolders) // Ensure feeds are created and grouped by their folders. let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: mirrorCollectionsAsFolders, log: log) createFeedsOperation.delegate = self createFeedsOperation.addDependency(mirrorCollectionsAsFolders) - self.operationQueue.addOperation(createFeedsOperation) + self.operationQueue.add(createFeedsOperation) + + let getAllArticleIds = FeedlyIngestStreamArticleIdsOperation(account: account, credentials: credentials, service: getStreamIdsService, log: log) + getAllArticleIds.delegate = self + getAllArticleIds.downloadProgress = downloadProgress + getAllArticleIds.addDependency(createFeedsOperation) + self.operationQueue.add(getAllArticleIds) // Get each page of unread article ids in the global.all stream for the last 31 days (nil = Feedly API default). - let getUnread = FeedlySyncUnreadStatusesOperation(account: account, credentials: credentials, service: getUnreadService, newerThan: nil, log: log) + let getUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: getUnreadService, database: database, newerThan: nil, log: log) getUnread.delegate = self - getUnread.addDependency(createFeedsOperation) + getUnread.addDependency(getAllArticleIds) getUnread.downloadProgress = downloadProgress - self.operationQueue.addOperation(getUnread) + self.operationQueue.add(getUnread) - // Get each page of the global.all stream until we get either the content from the last sync or the last 31 days. - let getStreamContents = FeedlySyncStreamContentsOperation(account: account, credentials: credentials, service: getStreamContentsService, newerThan: lastSuccessfulFetchStartDate, log: log) - getStreamContents.delegate = self - getStreamContents.downloadProgress = downloadProgress - getStreamContents.addDependency(getUnread) - self.operationQueue.addOperation(getStreamContents) + // Get each page of the article ids which have been update since the last successful fetch start date. + // If the date is nil, this operation provides an empty set (everything is new, nothing is updated). + let getUpdated = FeedlyGetUpdatedArticleIdsOperation(account: account, credentials: credentials, service: getStreamIdsService, newerThan: lastSuccessfulFetchStartDate, log: log) + getUpdated.delegate = self + getUpdated.downloadProgress = downloadProgress + getUpdated.addDependency(createFeedsOperation) + self.operationQueue.add(getUpdated) - // Get each and every starred article. - let syncStarred = FeedlySyncStarredArticlesOperation(account: account, credentials: credentials, service: getStarredArticlesService, log: log) - syncStarred.delegate = self - syncStarred.downloadProgress = downloadProgress - syncStarred.addDependency(createFeedsOperation) - self.operationQueue.addOperation(syncStarred) + // Get each page of the article ids for starred articles. + let getStarred = FeedlyIngestStarredArticleIdsOperation(account: account, credentials: credentials, service: getStarredService, database: database, newerThan: nil, log: log) + getStarred.delegate = self + getStarred.downloadProgress = downloadProgress + getStarred.addDependency(createFeedsOperation) + self.operationQueue.add(getStarred) + + // Now all the possible article ids we need have a status, fetch the article ids for missing articles. + let getMissingIds = FeedlyFetchIdsForMissingArticlesOperation(account: account, log: log) + getMissingIds.delegate = self + getMissingIds.downloadProgress = downloadProgress + getMissingIds.addDependency(getAllArticleIds) + getMissingIds.addDependency(getUnread) + getMissingIds.addDependency(getStarred) + getMissingIds.addDependency(getUpdated) + self.operationQueue.add(getMissingIds) + + // Download all the missing and updated articles + let downloadMissingArticles = FeedlyDownloadArticlesOperation(account: account, + missingArticleEntryIdProvider: getMissingIds, + updatedArticleEntryIdProvider: getUpdated, + getEntriesService: getEntriesService, + log: log) + downloadMissingArticles.delegate = self + downloadMissingArticles.downloadProgress = downloadProgress + downloadMissingArticles.addDependency(getMissingIds) + downloadMissingArticles.addDependency(getUpdated) + self.operationQueue.add(downloadMissingArticles) // Once this operation's dependencies, their dependencies etc finish, we can finish. let finishOperation = FeedlyCheckpointOperation() finishOperation.checkpointDelegate = self finishOperation.downloadProgress = downloadProgress - finishOperation.addDependency(getStreamContents) - finishOperation.addDependency(syncStarred) - - self.operationQueue.addOperation(finishOperation) + finishOperation.addDependency(downloadMissingArticles) + self.operationQueue.add(finishOperation) } convenience init(account: Account, credentials: Credentials, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, downloadProgress: DownloadProgress, log: OSLog) { - - let newerThan: Date? = { - if let date = lastSuccessfulFetchStartDate { - return date - } else { - return Calendar.current.date(byAdding: .day, value: -31, to: Date()) - } - }() - - self.init(account: account, credentials: credentials, lastSuccessfulFetchStartDate: newerThan, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredArticlesService: caller, database: database, downloadProgress: downloadProgress, log: log) + self.init(account: account, credentials: credentials, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredService: caller, getStreamIdsService: caller, getEntriesService: caller, database: database, downloadProgress: downloadProgress, log: log) } - override func cancel() { + override func run() { + os_log(.debug, log: log, "Starting sync %{public}@", syncUUID.uuidString) + operationQueue.resume() + } + + override func didCancel() { os_log(.debug, log: log, "Cancelling sync %{public}@", syncUUID.uuidString) self.operationQueue.cancelAllOperations() - - super.cancel() - - didFinish() - - // Operation should silently cancel. syncCompletionHandler = nil - } - - override func main() { - guard !isCancelled else { - // override of cancel calls didFinish(). - return - } - - os_log(.debug, log: log, "Starting sync %{public}@", syncUUID.uuidString) - operationQueue.isSuspended = false + super.didCancel() } } @@ -138,7 +159,9 @@ extension FeedlySyncAllOperation: FeedlyOperationDelegate { func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { assert(Thread.isMainThread) - os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", operation, error as NSError) + + // Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example. + os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError) syncCompletionHandler?(.failure(error)) syncCompletionHandler = nil diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncStarredArticlesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncStarredArticlesOperation.swift deleted file mode 100644 index b8da7d64e..000000000 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncStarredArticlesOperation.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// FeedlySyncStarredArticlesOperation.swift -// Account -// -// Created by Kiel Gillard on 15/10/19. -// Copyright © 2019 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import os.log -import RSParser - -final class FeedlySyncStarredArticlesOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate { - private let account: Account - private let operationQueue: OperationQueue - private let service: FeedlyGetStreamContentsService - private let log: OSLog - - private let setStatuses: FeedlySetStarredArticlesOperation - private let finishOperation: FeedlyCheckpointOperation - - /// Buffers every starred/saved entry from every page. - private class StarredEntryProvider: FeedlyEntryProviding, FeedlyStarredEntryIdProviding, FeedlyParsedItemProviding { - var resource: FeedlyResourceId - - private(set) var parsedEntries = Set() - private(set) var entries = [FeedlyEntry]() - - init(resource: FeedlyResourceId) { - self.resource = resource - } - - func addEntries(from provider: FeedlyEntryProviding & FeedlyParsedItemProviding) { - entries.append(contentsOf: provider.entries) - parsedEntries.formUnion(provider.parsedEntries) - } - - var entryIds: Set { - return Set(entries.map { $0.id }) - } - } - - private let entryProvider: StarredEntryProvider - - convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, log: OSLog) { - let saved = FeedlyTagResourceId.Global.saved(for: credentials.username) - self.init(account: account, resource: saved, service: service, log: log) - } - - init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, log: OSLog) { - self.account = account - self.service = service - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true - self.finishOperation = FeedlyCheckpointOperation() - self.log = log - - let provider = StarredEntryProvider(resource: resource) - self.entryProvider = provider - self.setStatuses = FeedlySetStarredArticlesOperation(account: account, - allStarredEntryIdsProvider: provider, - log: log) - - super.init() - - let getFirstPage = FeedlyGetStreamContentsOperation(account: account, - resource: resource, - service: service, - newerThan: nil, - log: log) - - let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, - parsedItemProvider: provider, - log: log) - - let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, - organisedItemsProvider: organiseByFeed, - log: log) - - getFirstPage.delegate = self - getFirstPage.streamDelegate = self - - setStatuses.addDependency(getFirstPage) - setStatuses.delegate = self - - organiseByFeed.addDependency(setStatuses) - organiseByFeed.delegate = self - - updateAccount.addDependency(organiseByFeed) - updateAccount.delegate = self - - finishOperation.checkpointDelegate = self - finishOperation.addDependency(updateAccount) - - let operations = [getFirstPage, setStatuses, organiseByFeed, updateAccount, finishOperation] - operationQueue.addOperations(operations, waitUntilFinished: false) - } - - override func cancel() { - os_log(.debug, log: log, "Canceling sync starred articles") - operationQueue.cancelAllOperations() - super.cancel() - didFinish() - } - - override func main() { - guard !isCancelled else { - // override of cancel calls didFinish(). - return - } - - operationQueue.isSuspended = false - } - - func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) { - guard !isCancelled else { - os_log(.debug, log: log, "Cancelled starred stream contents for %@", stream.id) - return - } - - entryProvider.addEntries(from: operation) - os_log(.debug, log: log, "Collecting %i items from %@", stream.items.count, stream.id) - - guard let continuation = stream.continuation else { - return - } - - let nextPageOperation = FeedlyGetStreamContentsOperation(account: operation.account, - resource: operation.resource, - service: operation.service, - continuation: continuation, - newerThan: operation.newerThan, - log: log) - nextPageOperation.delegate = self - nextPageOperation.streamDelegate = self - - setStatuses.addDependency(nextPageOperation) - operationQueue.addOperation(nextPageOperation) - } - - func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) { - didFinish() - } - - func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { - os_log(.debug, log: log, "%{public}@ failing and cancelling other operations because %{public}@.", operation, error.localizedDescription) - operationQueue.cancelAllOperations() - didFinish(error) - } -} diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift index 36e1360e6..7e98eea67 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift @@ -9,61 +9,59 @@ import Foundation import os.log import RSParser +import RSCore +import RSWeb final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate { + private let account: Account private let resource: FeedlyResourceId - private let operationQueue: OperationQueue + private let operationQueue = MainThreadOperationQueue() private let service: FeedlyGetStreamContentsService private let newerThan: Date? + private let isPagingEnabled: Bool private let log: OSLog private let finishOperation: FeedlyCheckpointOperation - init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) { + init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, isPagingEnabled: Bool, newerThan: Date?, log: OSLog) { self.account = account self.resource = resource self.service = service - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true + self.isPagingEnabled = isPagingEnabled + self.operationQueue.suspend() self.newerThan = newerThan self.log = log self.finishOperation = FeedlyCheckpointOperation() super.init() - self.operationQueue.addOperation(self.finishOperation) + self.operationQueue.add(self.finishOperation) self.finishOperation.checkpointDelegate = self enqueueOperations(for: nil) } convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) { let all = FeedlyCategoryResourceId.Global.all(for: credentials.username) - self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log) + self.init(account: account, resource: all, service: service, isPagingEnabled: true, newerThan: newerThan, log: log) } - override func cancel() { + override func run() { + operationQueue.resume() + } + + override func didCancel() { os_log(.debug, log: log, "Canceling sync stream contents") operationQueue.cancelAllOperations() - super.cancel() - didFinish() + super.didCancel() } - - override func main() { - guard !isCancelled else { - // override of cancel calls didFinish(). - return - } - - operationQueue.isSuspended = false - } - + func enqueueOperations(for continuation: String?) { os_log(.debug, log: log, "Requesting page for %@", resource.id) let operations = pageOperations(for: continuation) - operationQueue.addOperations(operations, waitUntilFinished: false) + operationQueue.addOperations(operations) } - func pageOperations(for continuation: String?) -> [Operation] { + func pageOperations(for continuation: String?) -> [MainThreadOperation] { let getPage = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: service, @@ -72,37 +70,33 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD log: log) - let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, - parsedItemProvider: getPage, - log: log) + let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: getPage, log: log) - let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, - organisedItemsProvider: organiseByFeed, - log: log) + let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: organiseByFeed, log: log) getPage.delegate = self getPage.streamDelegate = self - + organiseByFeed.addDependency(getPage) organiseByFeed.delegate = self - + updateAccount.addDependency(organiseByFeed) updateAccount.delegate = self - + finishOperation.addDependency(updateAccount) - + return [getPage, organiseByFeed, updateAccount] } func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) { - guard !isCancelled else { + guard !isCanceled else { os_log(.debug, log: log, "Cancelled requesting page for %@", resource.id) return } os_log(.debug, log: log, "Ingesting %i items from %@", stream.items.count, stream.id) - guard let continuation = stream.continuation else { + guard isPagingEnabled, let continuation = stream.continuation else { os_log(.debug, log: log, "Reached end of stream for %@", stream.id) return } @@ -117,6 +111,6 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { operationQueue.cancelAllOperations() - didFinish(error) + didFinish(with: error) } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncUnreadStatusesOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncUnreadStatusesOperation.swift deleted file mode 100644 index 9c015fd86..000000000 --- a/Frameworks/Account/Feedly/Operations/FeedlySyncUnreadStatusesOperation.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// FeedlySyncUnreadStatusesOperation.swift -// Account -// -// Created by Kiel Gillard on 18/10/19. -// Copyright © 2019 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import os.log -import RSParser - -/// Makes one or more requests to get the complete set of unread article ids to update the status of those articles *for the entire account.* -final class FeedlySyncUnreadStatusesOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamIdsOperationDelegate, FeedlyCheckpointOperationDelegate { - private let account: Account - private let resource: FeedlyResourceId - private let operationQueue: OperationQueue - private let service: FeedlyGetStreamIdsService - private let log: OSLog - - /// Buffers every unread article id from every page of the resource's stream. - private class UnreadEntryIdsProvider: FeedlyUnreadEntryIdProviding { - let resource: FeedlyResourceId - private(set) var entryIds = Set() - - init(resource: FeedlyResourceId) { - self.resource = resource - } - - func addEntryIds(from provider: FeedlyEntryIdenifierProviding) { - entryIds.formUnion(provider.entryIds) - } - } - - private let unreadEntryIdsProvider: UnreadEntryIdsProvider - private let setStatuses: FeedlySetUnreadArticlesOperation - - convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) { - let resource = FeedlyCategoryResourceId.Global.all(for: credentials.username) - self.init(account: account, resource: resource, service: service, newerThan: newerThan, log: log) - } - - init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) { - self.account = account - self.resource = resource - self.service = service - self.operationQueue = OperationQueue() - self.operationQueue.isSuspended = true - self.log = log - - let provider = UnreadEntryIdsProvider(resource: resource) - self.unreadEntryIdsProvider = provider - self.setStatuses = FeedlySetUnreadArticlesOperation(account: account, - allUnreadIdsProvider: unreadEntryIdsProvider, - log: log) - - super.init() - - let getFirstPageOfUnreadIds = FeedlyGetStreamIdsOperation(account: account, - resource: resource, - service: service, - newerThan: nil, - unreadOnly: true, - log: log) - - getFirstPageOfUnreadIds.delegate = self - getFirstPageOfUnreadIds.streamIdsDelegate = self - - setStatuses.addDependency(getFirstPageOfUnreadIds) - setStatuses.delegate = self - - let finishOperation = FeedlyCheckpointOperation() - finishOperation.checkpointDelegate = self - finishOperation.addDependency(setStatuses) - - let operations = [getFirstPageOfUnreadIds, setStatuses, finishOperation] - operationQueue.addOperations(operations, waitUntilFinished: false) - } - - override func cancel() { - os_log(.debug, log: log, "Canceling sync unread statuses") - operationQueue.cancelAllOperations() - super.cancel() - didFinish() - } - - override func main() { - guard !isCancelled else { - // override of cancel calls didFinish(). - return - } - - operationQueue.isSuspended = false - } - - func feedlyGetStreamIdsOperation(_ operation: FeedlyGetStreamIdsOperation, didGet streamIds: FeedlyStreamIds) { - guard !isCancelled else { - os_log(.debug, log: log, "Cancelled unread stream ids.") - return - } - - os_log(.debug, log: log, "Collecting %i unread article ids from %@", streamIds.ids.count, resource.id) - unreadEntryIdsProvider.addEntryIds(from: operation) - - guard let continuation = streamIds.continuation else { - return - } - - let nextPageOperation = FeedlyGetStreamIdsOperation(account: operation.account, - resource: operation.resource, - service: operation.service, - continuation: continuation, - newerThan: operation.newerThan, - unreadOnly: operation.unreadOnly, - log: log) - nextPageOperation.delegate = self - nextPageOperation.streamIdsDelegate = self - - setStatuses.addDependency(nextPageOperation) - operationQueue.addOperation(nextPageOperation) - } - - func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) { - didFinish() - } - - func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { - operationQueue.cancelAllOperations() - didFinish(error) - } -} diff --git a/Frameworks/Account/Feedly/Operations/FeedlyUpdateAccountFeedsWithItemsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyUpdateAccountFeedsWithItemsOperation.swift index b9820981e..b15d17cf9 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyUpdateAccountFeedsWithItemsOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyUpdateAccountFeedsWithItemsOperation.swift @@ -10,34 +10,29 @@ import Foundation import RSParser import os.log -/// Single responsibility is to combine the articles with their feeds for a specific account. +/// Combine the articles with their feeds for a specific account. final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation { + private let account: Account private let organisedItemsProvider: FeedlyParsedItemsByFeedProviding private let log: OSLog - + init(account: Account, organisedItemsProvider: FeedlyParsedItemsByFeedProviding, log: OSLog) { self.account = account self.organisedItemsProvider = organisedItemsProvider self.log = log } - override func main() { - precondition(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type. - guard !isCancelled else { - didFinish() - return - } - + override func run() { let webFeedIDsAndItems = organisedItemsProvider.parsedItemsKeyedByFeedId account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true) { databaseError in if let error = databaseError { - self.didFinish(error) + self.didFinish(with: error) return } - os_log(.debug, log: self.log, "Updated %i feeds for \"%@\"", webFeedIDsAndItems.count, self.organisedItemsProvider.providerName) + os_log(.debug, log: self.log, "Updated %i feeds for \"%@\"", webFeedIDsAndItems.count, self.organisedItemsProvider.parsedItemsByFeedProviderName) self.didFinish() } } diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index 31bb6e4e3..1cff74267 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -173,36 +173,47 @@ private extension Folder { extension Folder: OPMLRepresentable { - public func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { + public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { let attrExternalID: String = { - if !strictConformance, let externalID = externalID { - return " nnw_externalID=\"\(externalID)\"" + if allowCustomAttributes, let externalID = externalID { + return " nnw_externalID=\"\(externalID.escapingSpecialXMLCharacters)\"" } else { return "" } }() - let escapedTitle = nameForDisplay.rs_stringByEscapingSpecialXMLCharacters() + let escapedTitle = nameForDisplay.escapingSpecialXMLCharacters var s = "\n" - s = s.rs_string(byPrependingNumberOfTabs: indentLevel) + s = s.prepending(tabCount: indentLevel) var hasAtLeastOneChild = false - for feed in topLevelWebFeeds.sorted(by: { $0.nameForDisplay < $1.nameForDisplay }) { - s += feed.OPMLString(indentLevel: indentLevel + 1, strictConformance: strictConformance) + for feed in topLevelWebFeeds.sorted() { + s += feed.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes) hasAtLeastOneChild = true } if !hasAtLeastOneChild { s = "\n" - s = s.rs_string(byPrependingNumberOfTabs: indentLevel) + s = s.prepending(tabCount: indentLevel) return s } - s = s + NSString.rs_string(withNumberOfTabs: indentLevel) + "\n" + s = s + String(tabCount: indentLevel) + "\n" return s } } +// MARK: Set + +extension Set where Element == Folder { + + func sorted() -> Array { + return sorted(by: { (folder1, folder2) -> Bool in + return folder1.nameForDisplay.localizedStandardCompare(folder2.nameForDisplay) == .orderedAscending + }) + } + +} diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 2b7f198e2..870113d3d 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -53,12 +53,12 @@ extension LocalAccountRefresher: DownloadSessionDelegate { return nil } - let request = NSMutableURLRequest(url: url) + var request = URLRequest(url: url) if let conditionalGetInfo = feed.conditionalGetInfo { - conditionalGetInfo.addRequestHeadersToURLRequest(request) + conditionalGetInfo.addRequestHeadersToURLRequest(&request) } - return request as URLRequest + return request } func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) { @@ -73,7 +73,7 @@ extension LocalAccountRefresher: DownloadSessionDelegate { return } - let dataHash = (data as NSData).rs_md5HashString() + let dataHash = data.md5String if dataHash == feed.contentHash { completion() return @@ -137,6 +137,6 @@ private extension Data { func isDefinitelyNotFeed() -> Bool { // We only detect a few image types for now. This should get fleshed-out at some later date. - return (self as NSData).rs_dataIsImage() + return self.isImage } } diff --git a/Frameworks/Account/OPMLFile.swift b/Frameworks/Account/OPMLFile.swift index e6cc1917f..034efe4e1 100644 --- a/Frameworks/Account/OPMLFile.swift +++ b/Frameworks/Account/OPMLFile.swift @@ -17,62 +17,40 @@ final class OPMLFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) - + + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension OPMLFile { - - func loadCallback() { - guard let fileData = opmlFileData() else { + guard let fileData = opmlFileData(), let opmlItems = parsedOPMLItems(fileData: fileData) else { return } - // Don't rebuild the account if the OPML hasn't changed since the last save - guard let opml = String(data: fileData, encoding: .utf8), opml != opmlDocument() else { - return - } - - guard let opmlItems = parsedOPMLItems(fileData: fileData) else { return } - BatchUpdate.shared.perform { - account.topLevelWebFeeds.removeAll() account.loadOPMLItems(opmlItems, parentFolder: nil) } } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let opmlDocumentString = opmlDocument() let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -85,12 +63,28 @@ private extension OPMLFile { if let error = errorPointer?.pointee { os_log(.error, log: log, "OPML save to disk coordination failed: %@.", error.localizedDescription) } + } +} + +private extension OPMLFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + func opmlFileData() -> Data? { var fileData: Data? = nil let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in do { @@ -125,7 +119,7 @@ private extension OPMLFile { } func opmlDocument() -> String { - let escapedTitle = account.nameForDisplay.rs_stringByEscapingSpecialXMLCharacters() + let escapedTitle = account.nameForDisplay.escapingSpecialXMLCharacters let openingText = """ @@ -138,7 +132,7 @@ private extension OPMLFile { """ - let middleText = account.OPMLString(indentLevel: 0, strictConformance: false) + let middleText = account.OPMLString(indentLevel: 0, allowCustomAttributes: true) let closingText = """ diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index fc3c891dd..ad55d4e6c 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -598,14 +598,7 @@ private extension ReaderAPIAccountDelegate { os_log(.debug, log: log, "Syncing taggings with %ld subscriptions.", subscriptions.count) // Set up some structures to make syncing easier - let folderDict: [String: Folder] = { - if let folders = account.folders { - return Dictionary(uniqueKeysWithValues: folders.map { ($0.name ?? "", $0) } ) - } else { - return [String: Folder]() - } - }() - + let folderDict = nameToFolderDictionary(with: account.folders) let taggingsDict = subscriptions.reduce([String: [ReaderAPISubscription]]()) { (dict, subscription) in var taggedFeeds = dict @@ -667,6 +660,21 @@ private extension ReaderAPIAccountDelegate { } + func nameToFolderDictionary(with folders: Set?) -> [String: Folder] { + guard let folders = folders else { + return [String: Folder]() + } + + var d = [String: Folder]() + for folder in folders { + let name = folder.name ?? "" + if d[name] == nil { + d[name] = folder + } + } + return d + } + func sendArticleStatuses(_ statuses: [SyncStatus], apiCall: ([Int], @escaping (Result) -> Void) -> Void, completion: @escaping (() -> Void)) { diff --git a/Frameworks/Account/WebFeed.swift b/Frameworks/Account/WebFeed.swift index 8fd7d0e7c..179b7720d 100644 --- a/Frameworks/Account/WebFeed.swift +++ b/Frameworks/Account/WebFeed.swift @@ -42,8 +42,8 @@ public final class WebFeed: Feed, Renamable, Hashable { return metadata.homePageURL } set { - if let url = newValue { - metadata.homePageURL = url.rs_normalizedURL() + if let url = newValue, !url.isEmpty { + metadata.homePageURL = url.normalizedURL } else { metadata.homePageURL = nil @@ -245,7 +245,7 @@ public final class WebFeed: Feed, Renamable, Hashable { extension WebFeed: OPMLRepresentable { - public func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { + public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { // https://github.com/brentsimmons/NetNewsWire/issues/527 // Don’t use nameForDisplay because that can result in a feed name "Untitled" written to disk, // which NetNewsWire may take later to be the actual name. @@ -256,16 +256,16 @@ extension WebFeed: OPMLRepresentable { if nameToUse == nil { nameToUse = "" } - let escapedName = nameToUse!.rs_stringByEscapingSpecialXMLCharacters() + let escapedName = nameToUse!.escapingSpecialXMLCharacters var escapedHomePageURL = "" if let homePageURL = homePageURL { - escapedHomePageURL = homePageURL.rs_stringByEscapingSpecialXMLCharacters() + escapedHomePageURL = homePageURL.escapingSpecialXMLCharacters } - let escapedFeedURL = url.rs_stringByEscapingSpecialXMLCharacters() + let escapedFeedURL = url.escapingSpecialXMLCharacters var s = "\n" - s = s.rs_string(byPrependingNumberOfTabs: indentLevel) + s = s.prepending(tabCount: indentLevel) return s } @@ -276,4 +276,14 @@ extension Set where Element == WebFeed { func webFeedIDs() -> Set { return Set(map { $0.webFeedID }) } + + func sorted() -> Array { + return sorted(by: { (webFeed1, webFeed2) -> Bool in + if webFeed1.nameForDisplay.localizedStandardCompare(webFeed2.nameForDisplay) == .orderedSame { + return webFeed1.url < webFeed2.url + } + return webFeed1.nameForDisplay.localizedStandardCompare(webFeed2.nameForDisplay) == .orderedAscending + }) + } + } diff --git a/Frameworks/Account/WebFeedMetadataFile.swift b/Frameworks/Account/WebFeedMetadataFile.swift index e47c54582..8b0b3c029 100644 --- a/Frameworks/Account/WebFeedMetadataFile.swift +++ b/Frameworks/Account/WebFeedMetadataFile.swift @@ -16,41 +16,26 @@ final class WebFeedMetadataFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) - + + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension WebFeedMetadataFile { - - func loadCallback() { - let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in if let fileData = try? Data(contentsOf: readURL) { @@ -58,19 +43,14 @@ private extension WebFeedMetadataFile { account.webFeedMetadata = (try? decoder.decode(Account.WebFeedMetadataDictionary.self, from: fileData)) ?? Account.WebFeedMetadataDictionary() } account.webFeedMetadata.values.forEach { $0.delegate = account } - if !account.startingUp { - account.resetWebFeedMetadataAndUnreadCounts() - } }) if let error = errorPointer?.pointee { os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription) } - - } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let feedMetadata = metadataForOnlySubscribedToFeeds() @@ -79,7 +59,7 @@ private extension WebFeedMetadataFile { encoder.outputFormat = .binary let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -94,7 +74,22 @@ private extension WebFeedMetadataFile { os_log(.error, log: log, "Save to disk coordination failed: %@.", error.localizedDescription) } } - + +} + +private extension WebFeedMetadataFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + private func metadataForOnlySubscribedToFeeds() -> Account.WebFeedMetadataDictionary { let webFeedIDs = account.idToWebFeedDictionary.keys return account.webFeedMetadata.filter { (feedID: String, metadata: WebFeedMetadata) -> Bool in diff --git a/Frameworks/Articles/Article.swift b/Frameworks/Articles/Article.swift index 85ebe8385..6e13633cc 100644 --- a/Frameworks/Articles/Article.swift +++ b/Frameworks/Articles/Article.swift @@ -23,13 +23,12 @@ public struct Article: Hashable { public let externalURL: String? public let summary: String? public let imageURL: String? - public let bannerImageURL: String? public let datePublished: Date? public let dateModified: Date? public let authors: Set? public let status: ArticleStatus - public init(accountID: String, articleID: String?, webFeedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: Set?, status: ArticleStatus) { + public init(accountID: String, articleID: String?, webFeedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, datePublished: Date?, dateModified: Date?, authors: Set?, status: ArticleStatus) { self.accountID = accountID self.webFeedID = webFeedID self.uniqueID = uniqueID @@ -40,7 +39,6 @@ public struct Article: Hashable { self.externalURL = externalURL self.summary = summary self.imageURL = imageURL - self.bannerImageURL = bannerImageURL self.datePublished = datePublished self.dateModified = dateModified self.authors = authors diff --git a/Frameworks/Articles/DatabaseID.swift b/Frameworks/Articles/DatabaseID.swift index 392ca1870..86b6dc5de 100644 --- a/Frameworks/Articles/DatabaseID.swift +++ b/Frameworks/Articles/DatabaseID.swift @@ -26,7 +26,7 @@ public func databaseIDWithString(_ s: String) -> String { return identifier } - let identifier = (s as NSString).rs_md5Hash() + let identifier = s.md5String databaseIDCache[s] = identifier return identifier } diff --git a/Frameworks/Articles/xcconfig/Articles_project.xcconfig b/Frameworks/Articles/xcconfig/Articles_project.xcconfig index 8378c7f81..573284972 100644 --- a/Frameworks/Articles/xcconfig/Articles_project.xcconfig +++ b/Frameworks/Articles/xcconfig/Articles_project.xcconfig @@ -9,7 +9,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig" SDKROOT = macosx -MACOSX_DEPLOYMENT_TARGET = 10.13 +MACOSX_DEPLOYMENT_TARGET = 10.14 IPHONEOS_DEPLOYMENT_TARGET = 13.0 SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator @@ -18,7 +18,6 @@ SWIFT_VERSION = 5.1 COMBINE_HIDPI_IMAGES = YES COPY_PHASE_STRIP = NO -MACOSX_DEPLOYMENT_TARGET = 10.13 ALWAYS_SEARCH_USER_PATHS = NO CURRENT_PROJECT_VERSION = 1 VERSION_INFO_PREFIX = diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index c43e409bf..8b96e1f02 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -45,6 +45,7 @@ public final class ArticlesDatabase { private let articlesTable: ArticlesTable private let queue: DatabaseQueue + private let operationQueue = MainThreadOperationQueue() public init(databaseFilePath: String, accountID: String) { let queue = DatabaseQueue(databasePath: databaseFilePath) @@ -136,13 +137,36 @@ public final class ArticlesDatabase { } // MARK: - Unread Counts - - public func fetchUnreadCounts(for webFeedIDs: Set, _ completion: @escaping UnreadCountDictionaryCompletionBlock) { - articlesTable.fetchUnreadCounts(webFeedIDs, completion) + + /// Fetch all non-zero unread counts. + public func fetchAllUnreadCounts(_ completion: @escaping UnreadCountDictionaryCompletionBlock) { + let operation = FetchAllUnreadCountsOperation(databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate) + operationQueue.cancelOperations(named: operation.name!) + operation.completionBlock = { operation in + let fetchOperation = operation as! FetchAllUnreadCountsOperation + completion(fetchOperation.result) + } + operationQueue.add(operation) } - public func fetchAllNonZeroUnreadCounts(_ completion: @escaping UnreadCountDictionaryCompletionBlock) { - articlesTable.fetchAllUnreadCounts(completion) + /// Fetch unread count for a single feed. + public func fetchUnreadCount(_ webFeedID: String, _ completion: @escaping SingleUnreadCountCompletionBlock) { + let operation = FetchFeedUnreadCountOperation(webFeedID: webFeedID, databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate) + operation.completionBlock = { operation in + let fetchOperation = operation as! FetchFeedUnreadCountOperation + completion(fetchOperation.result) + } + operationQueue.add(operation) + } + + /// Fetch non-zero unread counts for given webFeedIDs. + public func fetchUnreadCounts(for webFeedIDs: Set, _ completion: @escaping UnreadCountDictionaryCompletionBlock) { + let operation = FetchUnreadCountsForFeedsOperation(webFeedIDs: webFeedIDs, databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate) + operation.completionBlock = { operation in + let fetchOperation = operation as! FetchUnreadCountsForFeedsOperation + completion(fetchOperation.result) + } + operationQueue.add(operation) } public func fetchUnreadCountForToday(for webFeedIDs: Set, completion: @escaping SingleUnreadCountCompletionBlock) { @@ -176,7 +200,7 @@ public final class ArticlesDatabase { articlesTable.fetchStarredArticleIDsAsync(webFeedIDs, completion) } - /// Fetch articleIDs for articles that we should have, but don’t. These articles are not userDeleted, and they are either (starred) or (unread and newer than the article cutoff date). + /// Fetch articleIDs for articles that we should have, but don’t. These articles are not userDeleted, and they are either (starred) or (newer than the article cutoff date). public func fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(_ completion: @escaping ArticleIDsCompletionBlock) { articlesTable.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(completion) } @@ -189,17 +213,31 @@ public final class ArticlesDatabase { articlesTable.mark(articleIDs, statusKey, flag, completion) } + /// Create statuses for specified articleIDs. For existing statuses, don’t do anything. + /// For newly-created statuses, mark them as read and not-starred. + public func createStatusesIfNeeded(articleIDs: Set, completion: @escaping DatabaseCompletionBlock) { + articlesTable.createStatusesIfNeeded(articleIDs, completion) + } + // MARK: - Suspend and Resume (for iOS) + /// Cancel current operations and close the database. + public func cancelAndSuspend() { + cancelOperations() + suspend() + } + /// Close the database and stop running database calls. /// Any pending calls will complete first. public func suspend() { + operationQueue.suspend() queue.suspend() } /// Open the database and allow for running database calls again. public func resume() { queue.resume() + operationQueue.resume() } // MARK: - Caches @@ -216,6 +254,7 @@ public final class ArticlesDatabase { /// Calls the various clean-up functions. public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set) { + articlesTable.deleteOldArticles() articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs) } } @@ -245,4 +284,10 @@ private extension ArticlesDatabase { // 24 hours previous. This is used by the Today smart feed, which should not actually empty out at midnight. return Date(timeIntervalSinceNow: -(60 * 60 * 24)) // This does not need to be more precise. } + + // MARK: - Operations + + func cancelOperations() { + operationQueue.cancelAllOperations() + } } diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj index f3241666f..055dd02a4 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.xcodeproj/project.pbxproj @@ -8,10 +8,10 @@ /* Begin PBXBuildFile section */ 51C451FF2264CF2100C03939 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C451FE2264CF2100C03939 /* RSParser.framework */; }; + 84116B8923E01E86000B2E98 /* FetchFeedUnreadCountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84116B8823E01E86000B2E98 /* FetchFeedUnreadCountOperation.swift */; }; 841D4D742106B59F00DD04E6 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D732106B59F00DD04E6 /* Articles.framework */; }; 84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */; }; 84288A021F6A3D8000395871 /* RelatedObjectsMap+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */; }; - 843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577151F744FC800F460AE /* DatabaseArticle.swift */; }; 843577221F749C6200F460AE /* ArticleChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577211F749C6200F460AE /* ArticleChangesTests.swift */; }; 843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */; }; 843CB9961F34174100EE6581 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20F901F1810DD00D8E682 /* Author+Database.swift */; }; @@ -20,9 +20,11 @@ 845580671F0AEBCD003CCFA1 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580661F0AEBCD003CCFA1 /* Constants.swift */; }; 845580761F0AF670003CCFA1 /* Article+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580751F0AF670003CCFA1 /* Article+Database.swift */; }; 8455807A1F0AF67D003CCFA1 /* ArticleStatus+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580791F0AF67D003CCFA1 /* ArticleStatus+Database.swift */; }; + 84611DCC23E62FE200BC630C /* FetchUnreadCountsForFeedsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84611DCB23E62FE200BC630C /* FetchUnreadCountsForFeedsOperation.swift */; }; 8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */; }; 848E3EB920FBCFD20004B7ED /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848E3EB820FBCFD20004B7ED /* RSCore.framework */; }; 848E3EBD20FBCFDE0004B7ED /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848E3EBC20FBCFDE0004B7ED /* RSDatabase.framework */; }; + 84C242C923DEB45C00C50516 /* FetchAllUnreadCountsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C242C823DEB45C00C50516 /* FetchAllUnreadCountsOperation.swift */; }; 84E156EA1F0AB80500F8CC05 /* ArticlesDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156E91F0AB80500F8CC05 /* ArticlesDatabase.swift */; }; 84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */; }; 84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */; }; @@ -112,10 +114,10 @@ /* Begin PBXFileReference section */ 518B2EA7235130CD00400001 /* ArticlesDatabase_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ArticlesDatabase_project_test.xcconfig; sourceTree = ""; }; 51C451FE2264CF2100C03939 /* RSParser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSParser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 84116B8823E01E86000B2E98 /* FetchFeedUnreadCountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchFeedUnreadCountOperation.swift; sourceTree = ""; }; 841D4D732106B59F00DD04E6 /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseObject+Database.swift"; sourceTree = ""; }; 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelatedObjectsMap+Database.swift"; sourceTree = ""; }; - 843577151F744FC800F460AE /* DatabaseArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseArticle.swift; sourceTree = ""; }; 843577211F749C6200F460AE /* ArticleChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleChangesTests.swift; sourceTree = ""; }; 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "ParsedArticle+Database.swift"; path = "Extensions/ParsedArticle+Database.swift"; sourceTree = ""; }; 844BEE371F0AB3AA004AB7CD /* ArticlesDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ArticlesDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -125,12 +127,14 @@ 845580661F0AEBCD003CCFA1 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 845580751F0AF670003CCFA1 /* Article+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Article+Database.swift"; path = "Extensions/Article+Database.swift"; sourceTree = ""; }; 845580791F0AF67D003CCFA1 /* ArticleStatus+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "ArticleStatus+Database.swift"; path = "Extensions/ArticleStatus+Database.swift"; sourceTree = ""; }; + 84611DCB23E62FE200BC630C /* FetchUnreadCountsForFeedsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchUnreadCountsForFeedsOperation.swift; sourceTree = ""; }; 8461461E1F0ABC7300870CB3 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTable.swift; sourceTree = ""; }; 848E3EB820FBCFD20004B7ED /* RSCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 848E3EBA20FBCFD80004B7ED /* RSParser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSParser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 848E3EBC20FBCFDE0004B7ED /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84BB4B8F1F119C4900858766 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = ""; }; + 84C242C823DEB45C00C50516 /* FetchAllUnreadCountsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchAllUnreadCountsOperation.swift; sourceTree = ""; }; 84E156E81F0AB75600F8CC05 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84E156E91F0AB80500F8CC05 /* ArticlesDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticlesDatabase.swift; sourceTree = ""; }; 84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticlesTable.swift; sourceTree = ""; }; @@ -176,9 +180,9 @@ 845580661F0AEBCD003CCFA1 /* Constants.swift */, 84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */, 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */, - 843577151F744FC800F460AE /* DatabaseArticle.swift */, 84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */, 84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */, + 84C242C723DEB42700C50516 /* Operations */, 8461462A1F0AC44100870CB3 /* Extensions */, 84E156E81F0AB75600F8CC05 /* Info.plist */, 844BEE441F0AB3AB004AB7CD /* DatabaseTests */, @@ -240,6 +244,16 @@ name = Products; sourceTree = ""; }; + 84C242C723DEB42700C50516 /* Operations */ = { + isa = PBXGroup; + children = ( + 84116B8823E01E86000B2E98 /* FetchFeedUnreadCountOperation.swift */, + 84611DCB23E62FE200BC630C /* FetchUnreadCountsForFeedsOperation.swift */, + 84C242C823DEB45C00C50516 /* FetchAllUnreadCountsOperation.swift */, + ); + path = Operations; + sourceTree = ""; + }; 84E156F21F0AB83600F8CC05 /* Products */ = { isa = PBXGroup; children = ( @@ -350,14 +364,14 @@ TargetAttributes = { 844BEE361F0AB3AA004AB7CD = { CreatedOnToolsVersion = 8.3.2; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; LastSwiftMigration = 0830; - ProvisioningStyle = Automatic; + ProvisioningStyle = Manual; }; 844BEE3F1F0AB3AB004AB7CD = { CreatedOnToolsVersion = 8.3.2; - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Automatic; + DevelopmentTeam = M8L2WTLA8W; + ProvisioningStyle = Manual; }; }; }; @@ -517,12 +531,14 @@ 845580671F0AEBCD003CCFA1 /* Constants.swift in Sources */, 843CB9961F34174100EE6581 /* Author+Database.swift in Sources */, 845580761F0AF670003CCFA1 /* Article+Database.swift in Sources */, + 84116B8923E01E86000B2E98 /* FetchFeedUnreadCountOperation.swift in Sources */, 8455807A1F0AF67D003CCFA1 /* ArticleStatus+Database.swift in Sources */, 84288A021F6A3D8000395871 /* RelatedObjectsMap+Database.swift in Sources */, + 84C242C923DEB45C00C50516 /* FetchAllUnreadCountsOperation.swift in Sources */, 84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */, 84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */, 8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */, - 843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */, + 84611DCC23E62FE200BC630C /* FetchUnreadCountsForFeedsOperation.swift in Sources */, 843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */, 84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */, 84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */, diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 877b1b0b6..1f0ab8414 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -19,14 +19,14 @@ final class ArticlesTable: DatabaseTable { private let queue: DatabaseQueue private let statusesTable: StatusesTable private let authorsLookupTable: DatabaseLookupTable - private var databaseArticlesCache = [String: DatabaseArticle]() + private var articlesCache = [String: Article]() private lazy var searchTable: SearchTable = { return SearchTable(queue: queue, articlesTable: self) }() // TODO: update articleCutoffDate as time passes and based on user preferences. - private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 90)! + let articleCutoffDate = Date().bySubtracting(days: 90) private typealias ArticlesFetchMethod = (FMDatabase) -> Set
@@ -212,6 +212,9 @@ final class ArticlesTable: DatabaseTable { self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7 + self.addArticlesToCache(newArticles) + self.addArticlesToCache(updatedArticles) + // 8. Update search index. if let newArticles = newArticles { self.searchTable.indexNewArticles(newArticles, database) @@ -234,31 +237,6 @@ final class ArticlesTable: DatabaseTable { // MARK: - Unread Counts - func fetchUnreadCounts(_ webFeedIDs: Set, _ completion: @escaping UnreadCountDictionaryCompletionBlock) { - if webFeedIDs.isEmpty { - completion(.success(UnreadCountDictionary())) - return - } - - fetchAllUnreadCounts { (unreadCountsResult) in - - func createUnreadCountDictionary(_ unreadCountDictionary: UnreadCountDictionary) -> UnreadCountDictionary { - var d = UnreadCountDictionary() - for webFeedID in webFeedIDs { - d[webFeedID] = unreadCountDictionary[webFeedID] ?? 0 - } - return d - } - - switch unreadCountsResult { - case .success(let unreadCountDictionary): - completion(.success(createUnreadCountDictionary(unreadCountDictionary))) - case .failure(let databaseError): - completion(.failure(databaseError)) - } - } - } - func fetchUnreadCount(_ webFeedIDs: Set, _ since: Date, _ completion: @escaping SingleUnreadCountCompletionBlock) { // Get unread count for today, for instance. if webFeedIDs.isEmpty { @@ -295,46 +273,6 @@ final class ArticlesTable: DatabaseTable { } } - func fetchAllUnreadCounts(_ completion: @escaping UnreadCountDictionaryCompletionBlock) { - // Returns only where unreadCount > 0. - - let cutoffDate = articleCutoffDate - queue.runInDatabase { databaseResult in - - func makeDatabaseCalls(_ database: FMDatabase) { - let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?))) group by feedID;" - - guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate, cutoffDate]) else { - DispatchQueue.main.async { - completion(.success(UnreadCountDictionary())) - } - return - } - - var d = UnreadCountDictionary() - while resultSet.next() { - let unreadCount = resultSet.long(forColumnIndex: 1) - if let webFeedID = resultSet.string(forColumnIndex: 0) { - d[webFeedID] = unreadCount - } - } - - DispatchQueue.main.async { - completion(.success(d)) - } - } - - switch databaseResult { - case .success(let database): - makeDatabaseCalls(database) - case .failure(let databaseError): - DispatchQueue.main.async { - completion(.failure(databaseError)) - } - } - } - } - func fetchStarredAndUnreadCount(_ webFeedIDs: Set, _ completion: @escaping SingleUnreadCountCompletionBlock) { if webFeedIDs.isEmpty { completion(.success(0)) @@ -418,6 +356,22 @@ final class ArticlesTable: DatabaseTable { } } + func createStatusesIfNeeded(_ articleIDs: Set, _ completion: @escaping DatabaseCompletionBlock) { + queue.runInTransaction { databaseResult in + switch databaseResult { + case .success(let database): + let _ = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, true, database) + DispatchQueue.main.async { + completion(nil) + } + case .failure(let databaseError): + DispatchQueue.main.async { + completion(databaseError) + } + } + } + } + // MARK: - Indexing func indexUnindexedArticles() { @@ -449,12 +403,30 @@ final class ArticlesTable: DatabaseTable { func emptyCaches() { queue.runInDatabase { _ in - self.databaseArticlesCache = [String: DatabaseArticle]() + self.articlesCache = [String: Article]() } } // MARK: - Cleanup + /// Delete articles that we won’t show in the UI any longer + /// — their arrival date is before our 90-day recency window. + /// Keep all starred articles, no matter their age. + func deleteOldArticles() { + queue.runInTransaction { databaseResult in + + func makeDatabaseCalls(_ database: FMDatabase) { + let sql = "delete from articles where articleID in (select articleID from articles natural join statuses where dateArrived Set
{ - // 1. Create DatabaseArticles without related objects. - // 2. Then fetch the related objects, given the set of articleIDs. - // 3. Then create set of Articles with DatabaseArticles and related objects and return it. + var cachedArticles = Set
() + var fetchedArticles = Set
() - // 1. Create databaseArticles (intermediate representations). + while resultSet.next() { - let databaseArticles = makeDatabaseArticles(with: resultSet) - if databaseArticles.isEmpty { - return Set
() - } - - let articleIDs = databaseArticles.articleIDs() - - // 2. Fetch related objects. - - let authorsMap = authorsLookupTable.fetchRelatedObjects(for: articleIDs, in: database) - - // 3. Create articles with related objects. - - let articles = databaseArticles.map { (databaseArticle) -> Article in - return articleWithDatabaseArticle(databaseArticle, authorsMap) - } - - return Set(articles) - } - - func articleWithDatabaseArticle(_ databaseArticle: DatabaseArticle, _ authorsMap: RelatedObjectsMap?) -> Article { - - let articleID = databaseArticle.articleID - let authors = authorsMap?.authors(for: articleID) - - return Article(databaseArticle: databaseArticle, accountID: accountID, authors: authors) - } - - func makeDatabaseArticles(with resultSet: FMResultSet) -> Set { - let articles = resultSet.mapToSet { (row) -> DatabaseArticle? in - - guard let articleID = row.string(forColumn: DatabaseKey.articleID) else { + guard let articleID = resultSet.string(forColumn: DatabaseKey.articleID) else { assertionFailure("Expected articleID.") - return nil + continue } - // Articles are removed from the cache when they’re updated. - // See saveUpdatedArticles. - if let databaseArticle = databaseArticlesCache[articleID] { - return databaseArticle + if let article = articlesCache[articleID] { + cachedArticles.insert(article) + continue } // The resultSet is a result of a JOIN query with the statuses table, // so we can get the statuses at the same time and avoid additional database lookups. guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else { assertionFailure("Expected status.") - return nil - } - guard let webFeedID = row.string(forColumn: DatabaseKey.feedID) else { - assertionFailure("Expected feedID.") - return nil - } - guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else { - assertionFailure("Expected uniqueID.") - return nil + continue } - let title = row.string(forColumn: DatabaseKey.title) - let contentHTML = row.string(forColumn: DatabaseKey.contentHTML) - let contentText = row.string(forColumn: DatabaseKey.contentText) - let url = row.string(forColumn: DatabaseKey.url) - let externalURL = row.string(forColumn: DatabaseKey.externalURL) - let summary = row.string(forColumn: DatabaseKey.summary) - let imageURL = row.string(forColumn: DatabaseKey.imageURL) - let bannerImageURL = row.string(forColumn: DatabaseKey.bannerImageURL) - let datePublished = row.date(forColumn: DatabaseKey.datePublished) - let dateModified = row.date(forColumn: DatabaseKey.dateModified) + guard let article = Article(accountID: accountID, row: resultSet, status: status) else { + continue + } + fetchedArticles.insert(article) + } + resultSet.close() - let databaseArticle = DatabaseArticle(articleID: articleID, webFeedID: webFeedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, status: status) - databaseArticlesCache[articleID] = databaseArticle - return databaseArticle + if fetchedArticles.isEmpty { + return cachedArticles } - return articles + // Fetch authors for non-cached articles. (Articles from the cache already have authors.) + let fetchedArticleIDs = fetchedArticles.articleIDs() + let authorsMap = authorsLookupTable.fetchRelatedObjects(for: fetchedArticleIDs, in: database) + let articlesWithFetchedAuthors = fetchedArticles.map { (article) -> Article in + if let authors = authorsMap?.authors(for: article.articleID) { + return article.byAdding(authors) + } + return article + } + + // Add fetchedArticles to cache, now that they have attached authors. + for article in articlesWithFetchedAuthors { + articlesCache[article.articleID] = article + } + + return cachedArticles.union(articlesWithFetchedAuthors) } func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set
{ @@ -615,8 +556,8 @@ private extension ArticlesTable { // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. if withLimits { - let sql = "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?)));" - return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject] + [articleCutoffDate as AnyObject], database) + let sql = "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or dateArrived>?);" + return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database) } else { let sql = "select * from articles natural join statuses where \(whereClause);" @@ -624,15 +565,15 @@ private extension ArticlesTable { } } - func fetchUnreadCount(_ webFeedID: String, _ database: FMDatabase) -> Int { - // Count only the articles that would appear in the UI. - // * Must be unread. - // * Must not be deleted. - // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. - - let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?)));" - return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate, articleCutoffDate], in: database) - } +// func fetchUnreadCount(_ webFeedID: String, _ database: FMDatabase) -> Int { +// // Count only the articles that would appear in the UI. +// // * Must be unread. +// // * Must not be deleted. +// // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. +// +// let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);" +// return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate], in: database) +// } func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set
{ let sql = "select rowid from search where search match ?;" @@ -872,7 +813,6 @@ private extension ArticlesTable { func saveUpdatedArticles(_ updatedArticles: Set
, _ fetchedArticles: [String: Article], _ database: FMDatabase) { - removeArticlesFromDatabaseArticlesCache(updatedArticles) saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database) for updatedArticle in updatedArticles { @@ -897,10 +837,12 @@ private extension ArticlesTable { updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database) } - func removeArticlesFromDatabaseArticlesCache(_ updatedArticles: Set
) { - let articleIDs = updatedArticles.articleIDs() - for articleID in articleIDs { - databaseArticlesCache[articleID] = nil + func addArticlesToCache(_ articles: Set
?) { + guard let articles = articles else { + return + } + for article in articles { + articlesCache[article.articleID] = article } } @@ -912,9 +854,6 @@ private extension ArticlesTable { if article.status.starred { return false } - if let datePublished = article.datePublished { - return datePublished < articleCutoffDate - } return article.status.dateArrived < articleCutoffDate } diff --git a/Frameworks/ArticlesDatabase/DatabaseArticle.swift b/Frameworks/ArticlesDatabase/DatabaseArticle.swift deleted file mode 100644 index d029f7113..000000000 --- a/Frameworks/ArticlesDatabase/DatabaseArticle.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// DatabaseArticle.swift -// NetNewsWire -// -// Created by Brent Simmons on 9/21/17. -// Copyright © 2017 Ranchero Software. All rights reserved. -// - -import Foundation -import Articles - -// Intermediate representation of an Article. Doesn’t include related objects. -// Used by ArticlesTable as part of fetching articles. - -struct DatabaseArticle: Hashable { - - let articleID: String - let webFeedID: String - let uniqueID: String - let title: String? - let contentHTML: String? - let contentText: String? - let url: String? - let externalURL: String? - let summary: String? - let imageURL: String? - let bannerImageURL: String? - let datePublished: Date? - let dateModified: Date? - let status: ArticleStatus - - // MARK: - Hashable - - public func hash(into hasher: inout Hasher) { - hasher.combine(articleID) - } -} - -extension Set where Element == DatabaseArticle { - - func articleIDs() -> Set { - return Set(map { $0.articleID }) - } -} diff --git a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift index 058922dfb..165887067 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift @@ -13,8 +13,31 @@ import RSParser extension Article { - init(databaseArticle: DatabaseArticle, accountID: String, authors: Set?) { - self.init(accountID: accountID, articleID: databaseArticle.articleID, webFeedID: databaseArticle.webFeedID, uniqueID: databaseArticle.uniqueID, title: databaseArticle.title, contentHTML: databaseArticle.contentHTML, contentText: databaseArticle.contentText, url: databaseArticle.url, externalURL: databaseArticle.externalURL, summary: databaseArticle.summary, imageURL: databaseArticle.imageURL, bannerImageURL: databaseArticle.bannerImageURL, datePublished: databaseArticle.datePublished, dateModified: databaseArticle.dateModified, authors: authors, status: databaseArticle.status) + init?(accountID: String, row: FMResultSet, status: ArticleStatus) { + guard let articleID = row.string(forColumn: DatabaseKey.articleID) else { + assertionFailure("Expected articleID.") + return nil + } + guard let webFeedID = row.string(forColumn: DatabaseKey.feedID) else { + assertionFailure("Expected feedID.") + return nil + } + guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else { + assertionFailure("Expected uniqueID.") + return nil + } + + let title = row.string(forColumn: DatabaseKey.title) + let contentHTML = row.string(forColumn: DatabaseKey.contentHTML) + let contentText = row.string(forColumn: DatabaseKey.contentText) + let url = row.string(forColumn: DatabaseKey.url) + let externalURL = row.string(forColumn: DatabaseKey.externalURL) + let summary = row.string(forColumn: DatabaseKey.summary) + let imageURL = row.string(forColumn: DatabaseKey.imageURL) + let datePublished = row.date(forColumn: DatabaseKey.datePublished) + let dateModified = row.date(forColumn: DatabaseKey.dateModified) + + self.init(accountID: accountID, articleID: articleID, webFeedID: webFeedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, datePublished: datePublished, dateModified: dateModified, authors: nil, status: status) } init(parsedItem: ParsedItem, maximumDateAllowed: Date, accountID: String, webFeedID: String, status: ArticleStatus) { @@ -34,7 +57,7 @@ extension Article { dateModified = nil } - self.init(accountID: accountID, articleID: parsedItem.syncServiceID, webFeedID: webFeedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, bannerImageURL: parsedItem.bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, status: status) + self.init(accountID: accountID, articleID: parsedItem.syncServiceID, webFeedID: webFeedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, status: status) } private func addPossibleStringChangeWithKeyPath(_ comparisonKeyPath: KeyPath, _ otherArticle: Article, _ key: String, _ dictionary: inout DatabaseDictionary) { @@ -42,7 +65,14 @@ extension Article { dictionary[key] = self[keyPath: comparisonKeyPath] ?? "" } } - + + func byAdding(_ authors: Set) -> Article { + if authors.isEmpty { + return self + } + return Article(accountID: self.accountID, articleID: self.articleID, webFeedID: self.webFeedID, uniqueID: self.uniqueID, title: self.title, contentHTML: self.contentHTML, contentText: self.contentText, url: self.url, externalURL: self.externalURL, summary: self.summary, imageURL: self.imageURL, datePublished: self.datePublished, dateModified: self.dateModified, authors: authors, status: self.status) + } + func changesFrom(_ existingArticle: Article) -> DatabaseDictionary? { if self == existingArticle { return nil @@ -60,7 +90,6 @@ extension Article { addPossibleStringChangeWithKeyPath(\Article.externalURL, existingArticle, DatabaseKey.externalURL, &d) addPossibleStringChangeWithKeyPath(\Article.summary, existingArticle, DatabaseKey.summary, &d) addPossibleStringChangeWithKeyPath(\Article.imageURL, existingArticle, DatabaseKey.imageURL, &d) - addPossibleStringChangeWithKeyPath(\Article.bannerImageURL, existingArticle, DatabaseKey.bannerImageURL, &d) // If updated versions of dates are nil, and we have existing dates, keep the existing dates. // This is data that’s good to have, and it’s likely that a feed removing dates is doing so in error. @@ -120,9 +149,6 @@ extension Article: DatabaseObject { if let imageURL = imageURL { d[DatabaseKey.imageURL] = imageURL } - if let bannerImageURL = bannerImageURL { - d[DatabaseKey.bannerImageURL] = bannerImageURL - } if let datePublished = datePublished { d[DatabaseKey.datePublished] = datePublished } diff --git a/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift new file mode 100644 index 000000000..24b81f87e --- /dev/null +++ b/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift @@ -0,0 +1,76 @@ +// +// FetchAllUnreadCountsOperation.swift +// ArticlesDatabase +// +// Created by Brent Simmons on 1/26/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import RSCore +import RSDatabase + +public final class FetchAllUnreadCountsOperation: MainThreadOperation { + + var result: UnreadCountDictionaryCompletionResult = .failure(.isSuspended) + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "FetchAllUnreadCountsOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private let queue: DatabaseQueue + private let cutoffDate: Date + + init(databaseQueue: DatabaseQueue, cutoffDate: Date) { + self.queue = databaseQueue + self.cutoffDate = cutoffDate + } + + public func run() { + queue.runInDatabase { databaseResult in + if self.isCanceled { + self.informOperationDelegateOfCompletion() + return + } + + switch databaseResult { + case .success(let database): + self.fetchUnreadCounts(database) + case .failure: + self.informOperationDelegateOfCompletion() + } + } + } +} + +private extension FetchAllUnreadCountsOperation { + + func fetchUnreadCounts(_ database: FMDatabase) { + let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and userDeleted=0 and (starred=1 or dateArrived>?) group by feedID;" + + guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) else { + informOperationDelegateOfCompletion() + return + } + + var unreadCountDictionary = UnreadCountDictionary() + while resultSet.next() { + if isCanceled { + resultSet.close() + informOperationDelegateOfCompletion() + return + } + let unreadCount = resultSet.long(forColumnIndex: 1) + if let webFeedID = resultSet.string(forColumnIndex: 0) { + unreadCountDictionary[webFeedID] = unreadCount + } + } + resultSet.close() + + result = .success(unreadCountDictionary) + informOperationDelegateOfCompletion() + } +} diff --git a/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift new file mode 100644 index 000000000..d3b0bd2d1 --- /dev/null +++ b/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift @@ -0,0 +1,74 @@ +// +// FetchFeedUnreadCountOperation.swift +// ArticlesDatabase +// +// Created by Brent Simmons on 1/27/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import RSCore +import RSDatabase + +/// Fetch the unread count for a single feed. +public final class FetchFeedUnreadCountOperation: MainThreadOperation { + + var result: SingleUnreadCountResult = .failure(.isSuspended) + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "FetchFeedUnreadCountOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private let queue: DatabaseQueue + private let cutoffDate: Date + private let webFeedID: String + + init(webFeedID: String, databaseQueue: DatabaseQueue, cutoffDate: Date) { + self.webFeedID = webFeedID + self.queue = databaseQueue + self.cutoffDate = cutoffDate + } + + public func run() { + queue.runInDatabase { databaseResult in + if self.isCanceled { + self.informOperationDelegateOfCompletion() + return + } + + switch databaseResult { + case .success(let database): + self.fetchUnreadCount(database) + case .failure: + self.informOperationDelegateOfCompletion() + } + } + } +} + +private extension FetchFeedUnreadCountOperation { + + func fetchUnreadCount(_ database: FMDatabase) { + let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);" + + guard let resultSet = database.executeQuery(sql, withArgumentsIn: [webFeedID, cutoffDate]) else { + informOperationDelegateOfCompletion() + return + } + if isCanceled { + informOperationDelegateOfCompletion() + return + } + + if resultSet.next() { + let unreadCount = resultSet.long(forColumnIndex: 0) + result = .success(unreadCount) + } + resultSet.close() + + informOperationDelegateOfCompletion() + } +} diff --git a/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift new file mode 100644 index 000000000..4b842bab5 --- /dev/null +++ b/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift @@ -0,0 +1,89 @@ +// +// FetchUnreadCountsForFeedsOperation.swift +// ArticlesDatabase +// +// Created by Brent Simmons on 2/1/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import RSCore +import RSDatabase + +/// Fetch the unread counts for a number of feeds. +public final class FetchUnreadCountsForFeedsOperation: MainThreadOperation { + + var result: UnreadCountDictionaryCompletionResult = .failure(.isSuspended) + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "FetchUnreadCountsForFeedsOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private let queue: DatabaseQueue + private let cutoffDate: Date + private let webFeedIDs: Set + + init(webFeedIDs: Set, databaseQueue: DatabaseQueue, cutoffDate: Date) { + self.webFeedIDs = webFeedIDs + self.queue = databaseQueue + self.cutoffDate = cutoffDate + } + + public func run() { + queue.runInDatabase { databaseResult in + if self.isCanceled { + self.informOperationDelegateOfCompletion() + return + } + + switch databaseResult { + case .success(let database): + self.fetchUnreadCounts(database) + case .failure: + self.informOperationDelegateOfCompletion() + } + } + } +} + +private extension FetchUnreadCountsForFeedsOperation { + + func fetchUnreadCounts(_ database: FMDatabase) { + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! + let sql = "select distinct feedID, count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and userDeleted=0 and (starred=1 or dateArrived>?) group by feedID;" + + var parameters = [Any]() + parameters += Array(webFeedIDs) as [Any] + parameters += [cutoffDate] as [Any] + + guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { + informOperationDelegateOfCompletion() + return + } + if isCanceled { + resultSet.close() + informOperationDelegateOfCompletion() + return + } + + var unreadCountDictionary = UnreadCountDictionary() + while resultSet.next() { + if isCanceled { + resultSet.close() + informOperationDelegateOfCompletion() + return + } + let unreadCount = resultSet.long(forColumnIndex: 1) + if let webFeedID = resultSet.string(forColumnIndex: 0) { + unreadCountDictionary[webFeedID] = unreadCount + } + } + resultSet.close() + + result = .success(unreadCountDictionary) + informOperationDelegateOfCompletion() + } +} diff --git a/Frameworks/ArticlesDatabase/SearchTable.swift b/Frameworks/ArticlesDatabase/SearchTable.swift index 6bcbd36bd..0733611a6 100644 --- a/Frameworks/ArticlesDatabase/SearchTable.swift +++ b/Frameworks/ArticlesDatabase/SearchTable.swift @@ -33,7 +33,7 @@ final class ArticleSearchInfo: Hashable { lazy var bodyForIndex: String = { let s = preferredText.rsparser_stringByDecodingHTMLEntities() - return s.rs_string(byStrippingHTML: 0).rs_stringWithCollapsedWhitespace() + return s.strippingHTML().collapsingWhitespace }() init(articleID: String, title: String?, contentHTML: String?, contentText: String?, summary: String?, searchRowID: Int?) { diff --git a/Frameworks/ArticlesDatabase/StatusesTable.swift b/Frameworks/ArticlesDatabase/StatusesTable.swift index a9e67d5eb..04916d3de 100644 --- a/Frameworks/ArticlesDatabase/StatusesTable.swift +++ b/Frameworks/ArticlesDatabase/StatusesTable.swift @@ -108,7 +108,7 @@ final class StatusesTable: DatabaseTable { var articleIDs = Set() func makeDatabaseCall(_ database: FMDatabase) { - let sql = "select articleID from statuses s where ((starred=1) || (read=0 and dateArrived > ?)) and userDeleted=0 and not exists (select 1 from articles a where a.articleID = s.articleID);" + let sql = "select articleID from statuses s where (starred=1 or dateArrived>?) and userDeleted=0 and not exists (select 1 from articles a where a.articleID = s.articleID);" if let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) { articleIDs = resultSet.mapToSet(self.articleIDWithRow) } diff --git a/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig b/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig index 8378c7f81..573284972 100644 --- a/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig +++ b/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig @@ -9,7 +9,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig" SDKROOT = macosx -MACOSX_DEPLOYMENT_TARGET = 10.13 +MACOSX_DEPLOYMENT_TARGET = 10.14 IPHONEOS_DEPLOYMENT_TARGET = 13.0 SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator @@ -18,7 +18,6 @@ SWIFT_VERSION = 5.1 COMBINE_HIDPI_IMAGES = YES COPY_PHASE_STRIP = NO -MACOSX_DEPLOYMENT_TARGET = 10.13 ALWAYS_SEARCH_USER_PATHS = NO CURRENT_PROJECT_VERSION = 1 VERSION_INFO_PREFIX = diff --git a/Frameworks/SyncDatabase/SyncDatabase.swift b/Frameworks/SyncDatabase/SyncDatabase.swift index 975bc5607..7790193e9 100644 --- a/Frameworks/SyncDatabase/SyncDatabase.swift +++ b/Frameworks/SyncDatabase/SyncDatabase.swift @@ -13,6 +13,9 @@ import RSDatabase public typealias SyncStatusesResult = Result, DatabaseError> public typealias SyncStatusesCompletionBlock = (SyncStatusesResult) -> Void +public typealias SyncStatusArticleIDsResult = Result, DatabaseError> +public typealias SyncStatusArticleIDsCompletionBlock = (SyncStatusArticleIDsResult) -> Void + public struct SyncDatabase { private let syncStatusTable: SyncStatusTable @@ -41,6 +44,14 @@ public struct SyncDatabase { syncStatusTable.selectPendingCount(completion) } + public func selectPendingReadStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) { + syncStatusTable.selectPendingReadStatusArticleIDs(completion: completion) + } + + public func selectPendingStarredStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) { + syncStatusTable.selectPendingStarredStatusArticleIDs(completion: completion) + } + public func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) { syncStatusTable.resetSelectedForProcessing(articleIDs, completion: completion) } diff --git a/Frameworks/SyncDatabase/SyncStatusTable.swift b/Frameworks/SyncDatabase/SyncStatusTable.swift index 04100c783..3d93ffab1 100644 --- a/Frameworks/SyncDatabase/SyncStatusTable.swift +++ b/Frameworks/SyncDatabase/SyncStatusTable.swift @@ -83,6 +83,14 @@ struct SyncStatusTable: DatabaseTable { } } + func selectPendingReadStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) { + selectPendingArticleIDsAsync(.read, completion) + } + + func selectPendingStarredStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) { + selectPendingArticleIDsAsync(.starred, completion) + } + func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) { queue.runInTransaction { databaseResult in @@ -156,6 +164,38 @@ private extension SyncStatusTable { return SyncStatus(articleID: articleID, key: key, flag: flag, selected: selected) } + + func selectPendingArticleIDsAsync(_ statusKey: ArticleStatus.Key, _ completion: @escaping SyncStatusArticleIDsCompletionBlock) { + + queue.runInDatabase { databaseResult in + + func makeDatabaseCall(_ database: FMDatabase) { + let sql = "select articleID from syncStatus where selected == false and key = \"\(statusKey.rawValue)\";" + + guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else { + DispatchQueue.main.async { + completion(.success(Set())) + } + return + } + + let articleIDs = resultSet.mapToSet{ $0.string(forColumnIndex: 0) } + DispatchQueue.main.async { + completion(.success(articleIDs)) + } + } + + switch databaseResult { + case .success(let database): + makeDatabaseCall(database) + case .failure(let databaseError): + DispatchQueue.main.async { + completion(.failure(databaseError)) + } + } + } + } + } private func callCompletion(_ completion: DatabaseCompletionBlock?, _ databaseError: DatabaseError?) { diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 44219001f..a138fea4b 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -226,8 +226,7 @@ struct AppDefaults { let showDebugMenu = false #endif - let defaults: [String : Any] = [Key.lastImageCacheFlushDate: Date(), - Key.sidebarFontSize: FontSize.medium.rawValue, + let defaults: [String : Any] = [Key.sidebarFontSize: FontSize.medium.rawValue, Key.timelineFontSize: FontSize.medium.rawValue, Key.detailFontSize: FontSize.medium.rawValue, Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 79296e81d..f344a7c26 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -85,7 +85,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, NSWindow.allowsAutomaticWindowTabbing = false super.init() - AccountManager.shared = AccountManager(accountsFolder: RSDataSubfolder(nil, "Accounts")!) + AccountManager.shared = AccountManager(accountsFolder: Platform.dataSubfolder(forApplication: nil, folderName: "Accounts")!) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(inspectableObjectsDidChange(_:)), name: .InspectableObjectsDidChange, object: nil) @@ -107,12 +107,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } func logDebugMessage(_ message: String) { - logMessage(message, type: .debug) } func showAddFolderSheetOnWindow(_ window: NSWindow) { - addFolderWindowController = AddFolderWindowController() addFolderWindowController!.runSheetOnWindow(window) } diff --git a/Mac/CrashReporter/CrashReporter.swift b/Mac/CrashReporter/CrashReporter.swift index be0a8b9f5..ddd3e5ad5 100644 --- a/Mac/CrashReporter/CrashReporter.swift +++ b/Mac/CrashReporter/CrashReporter.swift @@ -30,7 +30,7 @@ struct CrashLog { return nil } self.content = s - self.contentHash = s.rs_md5Hash() + self.contentHash = s.md5String self.path = path self.modificationDate = modificationDate } diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 13ac321fa..77071e7cd 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -98,8 +98,8 @@ class AddFeedController: AddFeedWindowControllerDelegate { private extension AddFeedController { var urlStringFromPasteboard: String? { - if let urlString = NSPasteboard.rs_urlString(from: NSPasteboard.general) { - return urlString.rs_normalizedURL() + if let urlString = NSPasteboard.urlString(from: NSPasteboard.general) { + return urlString.normalizedURL } return nil } diff --git a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift index df5f87868..f5ed2c9e8 100644 --- a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift @@ -36,7 +36,7 @@ class AddFeedWindowController : NSWindowController { private var userEnteredTitle: String? { var s = nameTextField.stringValue - s = s.rs_stringWithCollapsedWhitespace() + s = s.collapsingWhitespace if s.isEmpty { return nil } @@ -93,7 +93,7 @@ class AddFeedWindowController : NSWindowController { @IBAction func addFeed(_ sender: Any?) { let urlString = urlTextField.stringValue - let normalizedURLString = (urlString as NSString).rs_normalizedURL() + let normalizedURLString = urlString.normalizedURL if normalizedURLString.isEmpty { cancelSheet() @@ -130,7 +130,7 @@ class AddFeedWindowController : NSWindowController { private extension AddFeedWindowController { private func updateUI() { - addButton.isEnabled = urlTextField.stringValue.rs_stringMayBeURL() + addButton.isEnabled = urlTextField.stringValue.mayBeURL } func cancelSheet() { diff --git a/Shared/Article Rendering/ArticleIconSchemeHandler.swift b/Mac/MainWindow/Detail/DetailIconSchemeHandler.swift similarity index 91% rename from Shared/Article Rendering/ArticleIconSchemeHandler.swift rename to Mac/MainWindow/Detail/DetailIconSchemeHandler.swift index 20b0f32bc..4aee30c11 100644 --- a/Shared/Article Rendering/ArticleIconSchemeHandler.swift +++ b/Mac/MainWindow/Detail/DetailIconSchemeHandler.swift @@ -1,5 +1,5 @@ // -// AccountViewControllerSchemeHandler.swift +// DetailIconSchemeHandler.swift // NetNewsWire-iOS // // Created by Maurice Parker on 11/7/19. @@ -10,7 +10,7 @@ import Foundation import WebKit import Articles -class ArticleIconSchemeHandler: NSObject, WKURLSchemeHandler { +class DetailIconSchemeHandler: NSObject, WKURLSchemeHandler { var currentArticle: Article? diff --git a/Mac/MainWindow/Detail/DetailStatusBarView.swift b/Mac/MainWindow/Detail/DetailStatusBarView.swift index 0d1bfb6de..eea5f72d2 100644 --- a/Mac/MainWindow/Detail/DetailStatusBarView.swift +++ b/Mac/MainWindow/Detail/DetailStatusBarView.swift @@ -67,7 +67,7 @@ private extension DetailStatusBarView { func updateLinkForDisplay() { if let mouseoverLink = mouseoverLink, !mouseoverLink.isEmpty { - linkForDisplay = (mouseoverLink as NSString).rs_stringByStrippingHTTPOrHTTPSScheme() + linkForDisplay = mouseoverLink.strippingHTTPOrHTTPSScheme } else { linkForDisplay = nil diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index 1a334a958..6fd4a2de1 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -27,6 +27,17 @@ final class DetailWebViewController: NSViewController, WKUIDelegate { } } } + + var article: Article? { + switch state { + case .article(let article): + return article + case .extracted(let article, _): + return article + default: + return nil + } + } #if !MAC_APP_STORE private var webInspectorEnabled: Bool { @@ -39,7 +50,7 @@ final class DetailWebViewController: NSViewController, WKUIDelegate { } #endif - private let articleIconSchemeHandler = ArticleIconSchemeHandler() + private let detailIconSchemeHandler = DetailIconSchemeHandler() private var waitingForFirstReload = false private let keyboardDelegate = DetailKeyboardDelegate() @@ -66,7 +77,7 @@ final class DetailWebViewController: NSViewController, WKUIDelegate { let configuration = WKWebViewConfiguration() configuration.preferences = preferences - configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) + configuration.setURLSchemeHandler(detailIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) let userContentController = WKUserContentController() userContentController.add(self, name: MessageName.mouseDidEnter) @@ -107,7 +118,7 @@ final class DetailWebViewController: NSViewController, WKUIDelegate { NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) - webView.loadHTMLString(ArticleRenderer.page.html, baseURL: ArticleRenderer.page.baseURL) + webView.loadFileURL(ArticleRenderer.page.url, allowingReadAccessTo: ArticleRenderer.page.baseURL) } @@ -188,12 +199,22 @@ extension DetailWebViewController: WKNavigationDelegate { struct TemplateData: Codable { let style: String let body: String + let title: String + let baseURL: String } private extension DetailWebViewController { func reloadArticleImage() { - webView.evaluateJavaScript("reloadArticleImage()") + guard let article = article else { return } + + var components = URLComponents() + components.scheme = ArticleRenderer.imageIconScheme + components.path = article.articleID + + if let imageSrc = components.string { + webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")") + } } func reloadHTML() { @@ -208,14 +229,14 @@ private extension DetailWebViewController { case .loading: rendering = ArticleRenderer.loadingHTML(style: style) case .article(let article): - articleIconSchemeHandler.currentArticle = article + detailIconSchemeHandler.currentArticle = article rendering = ArticleRenderer.articleHTML(article: article, style: style) case .extracted(let article, let extractedArticle): - articleIconSchemeHandler.currentArticle = article + detailIconSchemeHandler.currentArticle = article rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style) } - let templateData = TemplateData(style: rendering.style, body: rendering.html) + let templateData = TemplateData(style: rendering.style, body: rendering.html, title: rendering.title, baseURL: rendering.baseURL) let encoder = JSONEncoder() var render = "error();" diff --git a/Mac/MainWindow/Detail/page.html b/Mac/MainWindow/Detail/page.html index b9cac098b..5ed1f5e46 100644 --- a/Mac/MainWindow/Detail/page.html +++ b/Mac/MainWindow/Detail/page.html @@ -1,11 +1,13 @@ - - - - + + + + + + - - - + + + diff --git a/Mac/MainWindow/Detail/styleSheet.css b/Mac/MainWindow/Detail/styleSheet.css index a98d508e1..4fd43f4ba 100644 --- a/Mac/MainWindow/Detail/styleSheet.css +++ b/Mac/MainWindow/Detail/styleSheet.css @@ -39,6 +39,7 @@ a:hover { --body-code-color: #666; --system-message-color: #cbcbcb; --feedlink-color: rgba(0, 0, 0, 0.6); + --table-cell-border-color: lightgray; } @media(prefers-color-scheme: dark) { @@ -50,7 +51,8 @@ a:hover { --header-color: #d2d2d2; --header-link-color: #4490e2; --body-code-color: #b2b2b2; - --system-message-color: #5f5f5f + --system-message-color: #5f5f5f; + --table-cell-border-color: dimgray; } } @@ -129,12 +131,48 @@ code, pre { font-family: "SF Mono", Menlo, "Courier New", Courier, monospace; font-size: 14px; } -img, figure, video, iframe, div { + +/* + Instead of the last-child bits, border-collapse: collapse + could have been used. However, then the inter-cell borders + overlap the table border, which looks bad. + */ +.nnw-overflow table { + margin-bottom: 1px; + border-spacing: 0; + border: 1px solid #777; +} +.nnw-overflow td, .nnw-overflow th { + -webkit-hyphens: none; + word-break: normal; + border: 1px solid var(--table-cell-border-color); + border-top: none; + border-left: none; + padding: 5px; +} +.nnw-overflow tr td:last-child, .nnw-overflow tr th:last-child { + border-right: none; +} +.nnw-overflow tr:last-child td, .nnw-overflow tr:last-child th { + border-bottom: none; +} +.nnw-overflow td pre { + border: none; + padding: 0; +} + +img, figure, iframe, div { max-width: 100%; height: auto !important; margin: 0 auto; } +video { + width: 100% !important; + height: auto !important; + margin: 0 auto; +} + figcaption { font-size: 14px; line-height: 1.3em; @@ -206,6 +244,7 @@ img[src*="share-buttons"] { .newsfoot-footnote-container { position: relative; display: inline-block; + z-index: 9999; } .newsfoot-footnote-popover { position: absolute; diff --git a/Mac/MainWindow/IconView.swift b/Mac/MainWindow/IconView.swift index e03ea6be4..55918692d 100644 --- a/Mac/MainWindow/IconView.swift +++ b/Mac/MainWindow/IconView.swift @@ -63,7 +63,7 @@ final class IconView: NSView { } override func resizeSubviews(withOldSize oldSize: NSSize) { - imageView.rs_setFrameIfNotEqual(rectForImageView()) + imageView.setFrame(ifNotEqualTo: rectForImageView()) } override func draw(_ dirtyRect: NSRect) { diff --git a/Mac/MainWindow/NNW3/NNW3Document.swift b/Mac/MainWindow/NNW3/NNW3Document.swift index 536f5500d..9d7fbed7f 100644 --- a/Mac/MainWindow/NNW3/NNW3Document.swift +++ b/Mac/MainWindow/NNW3/NNW3Document.swift @@ -32,7 +32,7 @@ struct NNW3Document { extension NNW3Document: OPMLRepresentable { - func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { + func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { var s = """ @@ -46,7 +46,7 @@ extension NNW3Document: OPMLRepresentable { if let children = children { for child in children { - s += child.OPMLString(indentLevel: indentLevel + 1, strictConformance: true) + s += child.OPMLString(indentLevel: indentLevel + 1) } } @@ -94,19 +94,19 @@ private struct NNW3Folder { extension NNW3Folder: OPMLRepresentable { - func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { - let t = title?.rs_stringByEscapingSpecialXMLCharacters() ?? "" + func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { + let t = title?.escapingSpecialXMLCharacters ?? "" guard let children = children else { // Empty folder. - return "\n".rs_string(byPrependingNumberOfTabs: indentLevel) + return "\n".prepending(tabCount: indentLevel) } - var s = "\n".rs_string(byPrependingNumberOfTabs: indentLevel) + var s = "\n".prepending(tabCount: indentLevel) for child in children { - s += child.OPMLString(indentLevel: indentLevel + 1, strictConformance: true) + s += child.OPMLString(indentLevel: indentLevel + 1) } - s += "\n".rs_string(byPrependingNumberOfTabs: indentLevel) + s += "\n".prepending(tabCount: indentLevel) return s } } @@ -130,13 +130,13 @@ private struct NNW3Feed { extension NNW3Feed: OPMLRepresentable { - func OPMLString(indentLevel: Int, strictConformance: Bool) -> String { - let t = title?.rs_stringByEscapingSpecialXMLCharacters() ?? "" - let p = homePageURL?.rs_stringByEscapingSpecialXMLCharacters() ?? "" - let f = feedURL?.rs_stringByEscapingSpecialXMLCharacters() ?? "" + func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String { + let t = title?.escapingSpecialXMLCharacters ?? "" + let p = homePageURL?.escapingSpecialXMLCharacters ?? "" + let f = feedURL?.escapingSpecialXMLCharacters ?? "" var s = "\n" - s = s.rs_string(byPrependingNumberOfTabs: indentLevel) + s = s.prepending(tabCount: indentLevel) return s } diff --git a/Mac/MainWindow/NNW3/NNW3ImportController.swift b/Mac/MainWindow/NNW3/NNW3ImportController.swift index f3103e1e1..9459626bf 100644 --- a/Mac/MainWindow/NNW3/NNW3ImportController.swift +++ b/Mac/MainWindow/NNW3/NNW3ImportController.swift @@ -93,7 +93,7 @@ private extension NNW3ImportController { guard let document = NNW3Document(subscriptionsPlistURL: url) else { return nil } - let opml = document.OPMLString(indentLevel: 0, strictConformance: true) + let opml = document.OPMLString(indentLevel: 0) let opmlURL = FileManager.default.temporaryDirectory.appendingPathComponent("NNW3.opml") do { diff --git a/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift b/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift index 7b021a073..883c859d7 100644 --- a/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift +++ b/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift @@ -142,9 +142,9 @@ private extension SidebarCell { } func layoutWith(_ layout: SidebarCellLayout) { - faviconImageView.rs_setFrameIfNotEqual(layout.faviconRect) - titleView.rs_setFrameIfNotEqual(layout.titleRect) - unreadCountView.rs_setFrameIfNotEqual(layout.unreadCountRect) + faviconImageView.setFrame(ifNotEqualTo: layout.faviconRect) + titleView.setFrame(ifNotEqualTo: layout.titleRect) + unreadCountView.setFrame(ifNotEqualTo: layout.unreadCountRect) } } diff --git a/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift b/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift index 90b2aba1f..e054b924a 100644 --- a/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift +++ b/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift @@ -24,7 +24,7 @@ struct SidebarCellLayout { var rFavicon = NSRect.zero if shouldShowImage { rFavicon = NSRect(x: 0.0, y: 0.0, width: appearance.imageSize.width, height: appearance.imageSize.height) - rFavicon = RSRectCenteredVerticallyInRect(rFavicon, bounds) + rFavicon = rFavicon.centeredVertically(in: bounds) } self.faviconRect = rFavicon @@ -34,7 +34,7 @@ struct SidebarCellLayout { if shouldShowImage { rTextField.origin.x = NSMaxX(rFavicon) + appearance.imageMarginRight } - rTextField = RSRectCenteredVerticallyInRect(rTextField, bounds) + rTextField = rTextField.centeredVertically(in: bounds) let unreadCountSize = unreadCountView.intrinsicContentSize let unreadCountIsHidden = unreadCountView.unreadCount < 1 @@ -43,7 +43,7 @@ struct SidebarCellLayout { if !unreadCountIsHidden { rUnread.size = unreadCountSize rUnread.origin.x = NSMaxX(bounds) - unreadCountSize.width - rUnread = RSRectCenteredVerticallyInRect(rUnread, bounds) + rUnread = rUnread.centeredVertically(in: bounds) let textFieldMaxX = NSMinX(rUnread) - appearance.unreadCountMarginLeft if NSMaxX(rTextField) > textFieldMaxX { rTextField.size.width = textFieldMaxX - NSMinX(rTextField) diff --git a/Mac/MainWindow/Sidebar/PasteboardWebFeed.swift b/Mac/MainWindow/Sidebar/PasteboardWebFeed.swift index 1c84cb78b..31612ded9 100644 --- a/Mac/MainWindow/Sidebar/PasteboardWebFeed.swift +++ b/Mac/MainWindow/Sidebar/PasteboardWebFeed.swift @@ -37,9 +37,9 @@ struct PasteboardWebFeed: Hashable { let isLocalFeed: Bool init(url: String, webFeedID: String?, homePageURL: String?, name: String?, editedName: String?, accountID: String?, accountType: AccountType?) { - self.url = url.rs_normalizedURL() + self.url = url.normalizedURL self.webFeedID = webFeedID - self.homePageURL = homePageURL?.rs_normalizedURL() + self.homePageURL = homePageURL?.normalizedURL self.name = name self.editedName = editedName self.accountID = accountID @@ -93,7 +93,7 @@ struct PasteboardWebFeed: Hashable { } if let foundType = pasteboardType { if let possibleURLString = pasteboardItem.string(forType: foundType) { - if possibleURLString.rs_stringMayBeURL() { + if possibleURLString.mayBeURL { self.init(url: possibleURLString, webFeedID: nil, homePageURL: nil, name: nil, editedName: nil, accountID: nil, accountType: nil) return } diff --git a/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift b/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift index 29ec5adca..b962ba8e6 100644 --- a/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift +++ b/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift @@ -78,7 +78,7 @@ private extension ArticlePasteboardWriter { s += "\(summary)\n\n" } else if let html = article.contentHTML { - let convertedHTML = html.rs_stringByConvertingToPlainText() + let convertedHTML = html.convertingToPlainText() s += "\(convertedHTML)\n\n" } @@ -151,7 +151,6 @@ private extension ArticlePasteboardWriter { d[Key.externalURL] = article.externalURL ?? nil d[Key.summary] = article.summary ?? nil d[Key.imageURL] = article.imageURL ?? nil - d[Key.bannerImageURL] = article.bannerImageURL ?? nil d[Key.datePublished] = article.datePublished ?? nil d[Key.dateModified] = article.dateModified ?? nil d[Key.dateArrived] = article.status.dateArrived diff --git a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift index 2271a7a53..7e4726028 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -116,12 +116,12 @@ class TimelineTableCellView: NSTableCellView { setFrame(for: summaryView, rect: layoutRects.summaryRect) setFrame(for: textView, rect: layoutRects.textRect) - dateView.rs_setFrameIfNotEqual(layoutRects.dateRect) - unreadIndicatorView.rs_setFrameIfNotEqual(layoutRects.unreadIndicatorRect) - feedNameView.rs_setFrameIfNotEqual(layoutRects.feedNameRect) - iconView.rs_setFrameIfNotEqual(layoutRects.iconImageRect) - starView.rs_setFrameIfNotEqual(layoutRects.starRect) - separatorView.rs_setFrameIfNotEqual(layoutRects.separatorRect) + dateView.setFrame(ifNotEqualTo: layoutRects.dateRect) + unreadIndicatorView.setFrame(ifNotEqualTo: layoutRects.unreadIndicatorRect) + feedNameView.setFrame(ifNotEqualTo: layoutRects.feedNameRect) + iconView.setFrame(ifNotEqualTo: layoutRects.iconImageRect) + starView.setFrame(ifNotEqualTo: layoutRects.starRect) + separatorView.setFrame(ifNotEqualTo: layoutRects.separatorRect) } } @@ -172,7 +172,7 @@ private extension TimelineTableCellView { } else { showView(textField) - textField.rs_setFrameIfNotEqual(rect) + textField.setFrame(ifNotEqualTo: rect) } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index bb79897f9..31daeb2ab 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -285,7 +285,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr tableView.scrollTo(row: 0, extraHeight: 0) } - tableView.rs_selectRow(nextRowIndex) + tableView.selectRow(nextRowIndex) let followingRowIndex = nextRowIndex - 1 if followingRowIndex < 0 { @@ -307,7 +307,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr tableView.scrollTo(row: tableMaxIndex, extraHeight: 0) } - tableView.rs_selectRow(nextRowIndex) + tableView.selectRow(nextRowIndex) let followingRowIndex = nextRowIndex + 1 if followingRowIndex > tableMaxIndex { @@ -412,7 +412,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr guard let ix = articles.firstIndex(where: { $0.articleID == articleID }) else { return } NSCursor.setHiddenUntilMouseMoves(true) - tableView.rs_selectRow(ix) + tableView.selectRow(ix) tableView.scrollTo(row: ix) } @@ -421,7 +421,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } NSCursor.setHiddenUntilMouseMoves(true) - tableView.rs_selectRow(ix) + tableView.selectRow(ix) tableView.scrollTo(row: ix) } @@ -443,7 +443,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr window.makeFirstResponderUnlessDescendantIsFirstResponder(tableView) if !hasAtLeastOneSelectedArticle && articles.count > 0 { - tableView.rs_selectRowAndScrollToVisible(0) + tableView.selectRowAndScrollToVisible(0) } } @@ -588,7 +588,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr let longTitle = "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?" let prototypeID = "prototype" let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date()) - let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) + let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) let prototypeCellData = TimelineCellData(article: prototypeArticle, showFeedName: showingFeedNames, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil) let height = TimelineCellLayout.height(for: 100, cellData: prototypeCellData, appearance: cellAppearance) diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index bfd0050b8..d1a8a76d4 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -8,6 +8,7 @@ import AppKit import Account +import RSCore class AccountsAddViewController: NSViewController { @@ -111,7 +112,7 @@ extension AccountsAddViewController: NSTableViewDelegate { let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) addAccount.delegate = self addAccount.presentationAnchor = self.view.window! - OperationQueue.main.addOperation(addAccount) + MainThreadOperationQueue.shared.add(addAccount) default: break } diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 3636e7670..663f9f6fe 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -149,7 +149,7 @@ private extension AccountsPreferencesViewController { addChild(controller) controller.view.translatesAutoresizingMaskIntoConstraints = false detailView.addSubview(controller.view) - detailView.rs_addFullSizeConstraints(forSubview: controller.view) + detailView.addFullSizeConstraints(forSubview: controller.view) } diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index 4a47f7ad4..57996a485 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -177,7 +177,7 @@ private struct RSSReader: Hashable { let name = (path as NSString).lastPathComponent self.name = name if name.hasSuffix(".app") { - self.nameMinusAppSuffix = name.rs_string(byStrippingSuffix: ".app", caseSensitive: false) + self.nameMinusAppSuffix = name.stripping(suffix: ".app") } else { self.nameMinusAppSuffix = name diff --git a/Mac/Resources/NetNewsWire.entitlements b/Mac/Resources/NetNewsWire.entitlements index ef6f5c233..ba32d8233 100644 --- a/Mac/Resources/NetNewsWire.entitlements +++ b/Mac/Resources/NetNewsWire.entitlements @@ -2,8 +2,8 @@ - com.apple.security.app-sandbox - + com.apple.security.app-sandbox + com.apple.developer.icloud-container-identifiers com.apple.security.automation.apple-events @@ -12,5 +12,9 @@ com.apple.security.network.client + com.apple.security.temporary-exception.apple-events + + com.red-sweater.marsedit4 + diff --git a/Mac/Scriptability/Account+Scriptability.swift b/Mac/Scriptability/Account+Scriptability.swift index 7d6f7428b..e4ad348ef 100644 --- a/Mac/Scriptability/Account+Scriptability.swift +++ b/Mac/Scriptability/Account+Scriptability.swift @@ -150,7 +150,7 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta @objc(opmlRepresentation) var opmlRepresentation:String { - return self.account.OPMLString(indentLevel:0, strictConformance: true) + return self.account.OPMLString(indentLevel:0) } @objc(accountType) @@ -170,6 +170,6 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta case .freshRSS: osType = "Frsh" } - return osType.fourCharCode() + return osType.fourCharCode } } diff --git a/Mac/Scriptability/AppDelegate+Scriptability.swift b/Mac/Scriptability/AppDelegate+Scriptability.swift index 84bed12b1..68decde44 100644 --- a/Mac/Scriptability/AppDelegate+Scriptability.swift +++ b/Mac/Scriptability/AppDelegate+Scriptability.swift @@ -44,8 +44,8 @@ extension AppDelegate : AppDelegateAppleEvents { return } - let normalizedURLString = urlString.rs_normalizedURL() - if !normalizedURLString.rs_stringMayBeURL() { + let normalizedURLString = urlString.normalizedURL + if !normalizedURLString.mayBeURL { return } diff --git a/Mac/Scriptability/Folder+Scriptability.swift b/Mac/Scriptability/Folder+Scriptability.swift index 2229ce3a7..9e25698d4 100644 --- a/Mac/Scriptability/Folder+Scriptability.swift +++ b/Mac/Scriptability/Folder+Scriptability.swift @@ -110,7 +110,7 @@ class ScriptableFolder: NSObject, UniqueIdScriptingObject, ScriptingObjectContai @objc(opmlRepresentation) var opmlRepresentation:String { - return self.folder.OPMLString(indentLevel:0, strictConformance: true) + return self.folder.OPMLString(indentLevel:0) } } diff --git a/Mac/Scriptability/NSScriptCommand+NetNewsWire.swift b/Mac/Scriptability/NSScriptCommand+NetNewsWire.swift index 6d7db56d3..1cc5e36e5 100644 --- a/Mac/Scriptability/NSScriptCommand+NetNewsWire.swift +++ b/Mac/Scriptability/NSScriptCommand+NetNewsWire.swift @@ -22,7 +22,7 @@ extension NSScriptCommand { func isCreateCommand(forClass whatClass:String) -> Bool { guard let arguments = self.arguments else {return false} guard let newObjectClass = arguments["ObjectClass"] as? Int else {return false} - guard (newObjectClass.fourCharCode() == whatClass.fourCharCode()) else {return false} + guard (newObjectClass.fourCharCode == whatClass.fourCharCode) else {return false} return true } @@ -36,12 +36,12 @@ extension NSScriptCommand { print("insertionLocation : \(insertionLocationDescriptor)") // insertion location can be a typeObjectSpecifier, e.g. 'in account "Acct"' // or a typeInsertionLocation, e.g. 'at end of folder " - if (insertionLocationDescriptor.descriptorType == "insl".fourCharCode()) { - descriptorToConsider = insertionLocationDescriptor.forKeyword("kobj".fourCharCode()) - } else if ( insertionLocationDescriptor.descriptorType == "obj ".fourCharCode()) { + if (insertionLocationDescriptor.descriptorType == "insl".fourCharCode) { + descriptorToConsider = insertionLocationDescriptor.forKeyword("kobj".fourCharCode) + } else if ( insertionLocationDescriptor.descriptorType == "obj ".fourCharCode) { descriptorToConsider = insertionLocationDescriptor } - } else if let subjectDescriptor = appleEvent.attributeDescriptor(forKeyword:"subj".fourCharCode()) { + } else if let subjectDescriptor = appleEvent.attributeDescriptor(forKeyword:"subj".fourCharCode) { descriptorToConsider = subjectDescriptor } diff --git a/Mac/Scriptability/WebFeed+Scriptability.swift b/Mac/Scriptability/WebFeed+Scriptability.swift index 6c20c3dea..d99b84f01 100644 --- a/Mac/Scriptability/WebFeed+Scriptability.swift +++ b/Mac/Scriptability/WebFeed+Scriptability.swift @@ -146,7 +146,7 @@ class ScriptableWebFeed: NSObject, UniqueIdScriptingObject, ScriptingObjectConta @objc(opmlRepresentation) var opmlRepresentation:String { - return self.webFeed.OPMLString(indentLevel:0, strictConformance: true) + return self.webFeed.OPMLString(indentLevel:0) } // MARK: --- scriptable elements --- diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 3e8b9b83f..51a138d08 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 5127B23A222B4849006D641D /* DetailKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5127B237222B4849006D641D /* DetailKeyboardShortcuts.plist */; }; 512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */; }; 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */; }; + 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */; }; 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; }; 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* WebFeedTreeControllerDelegate.swift */; }; 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */; }; @@ -71,7 +72,6 @@ 513C5D0C232574DA003D4054 /* RSTree.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84C37F9520DD8CFE00CA8CF5 /* RSTree.framework */; }; 513C5D0E232574E4003D4054 /* SyncDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51554C01228B6EB50055115A /* SyncDatabase.framework */; }; 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */; }; - 5141E7562374A2890013FF27 /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7552374A2890013FF27 /* ArticleIconSchemeHandler.swift */; }; 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5142192923522B5500E07E2C /* ImageViewController.swift */; }; 514219372352510100E07E2C /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514219362352510100E07E2C /* ImageScrollView.swift */; }; 5142194B2353C1CF00E07E2C /* main_mac.js in Resources */ = {isa = PBXBuildFile; fileRef = 5142194A2353C1CF00E07E2C /* main_mac.js */; }; @@ -110,7 +110,7 @@ 51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */; }; 517630042336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; 517630052336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; - 517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */; }; + 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* WebViewProvider.swift */; }; 5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */; }; 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */; }; 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */; }; @@ -120,8 +120,9 @@ 518651DA235621840078E021 /* ImageTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518651D9235621840078E021 /* ImageTransition.swift */; }; 5186A635235EF3A800C97195 /* VibrantLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5186A634235EF3A800C97195 /* VibrantLabel.swift */; }; 518B2EE82351B45600400001 /* NetNewsWire_iOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840D61952029031D009BC708 /* NetNewsWire_iOSTests.swift */; }; - 518C3193237B00D9004D740F /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7552374A2890013FF27 /* ArticleIconSchemeHandler.swift */; }; - 518C3194237B00DA004D740F /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7552374A2890013FF27 /* ArticleIconSchemeHandler.swift */; }; + 518C3193237B00D9004D740F /* DetailIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */; }; + 518C3194237B00DA004D740F /* DetailIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */; }; + 518ED21D23D0F26000E0A862 /* UIViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518ED21C23D0F26000E0A862 /* UIViewController-Extensions.swift */; }; 51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51934CC1230F5963006127BE /* InteractiveNavigationController.swift */; }; 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51934CCD2310792F006127BE /* ActivityManager.swift */; }; 51938DF2231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */; }; @@ -136,7 +137,6 @@ 51A1699F235E10D700EB091F /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16995235E10D600EB091F /* AboutViewController.swift */; }; 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */; }; 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; - 51A9A5E02380C3F10033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; }; 51A9A5E42380C8880033AADF /* ShareFolderPickerAccountCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */; }; 51A9A5E62380C8B20033AADF /* ShareFolderPickerFolderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */; }; @@ -148,6 +148,20 @@ 51A9A5F32380DE530033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */; }; 51A9A60A2382FD240033AADF /* PoppableGestureRecognizerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */; }; + 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AB8AB223B7F4C6008F147D /* WebViewController.swift */; }; + 51B5C87723F22B8200032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8B923F368D000032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C8BA23F368D000032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8BB23F368D000032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C8BE23F37B2400032075 /* ShareDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */; }; + 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; + 51B5C8C123F3A0DB00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; + 51B5C8E423F4BBFA00032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C8E523F4BBFA00032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8E623F4BBFA00032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C8E723F4BBFA00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; 51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; }; 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; }; 51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; }; @@ -222,6 +236,8 @@ 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F204DF1FAACBB30076E152 /* ArticleArray.swift */; }; 51C452B42265141B00C03939 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C452B32265141B00C03939 /* WebKit.framework */; }; 51C452B82265178500C03939 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 51C452B72265178500C03939 /* styleSheet.css */; }; + 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */; }; + 51C9DE8623F09FAA003D5A6D /* AccountMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */; }; 51CE1C0923621EDA005548FC /* RefreshProgressView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */; }; 51CE1C0B23622007005548FC /* RefreshProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CE1C0A23622006005548FC /* RefreshProgressView.swift */; }; 51CE1C712367721A005548FC /* testURLsOfCurrentArticle.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EADB213660A100CF2DE4 /* testURLsOfCurrentArticle.applescript */; }; @@ -255,6 +271,9 @@ 51F85BF92274AA7B00C787DC /* UIBarButtonItem-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */; }; 51F85BFB2275D85000C787DC /* Array-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFA2275D85000C787DC /* Array-Extensions.swift */; }; 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */; }; + 51F9F3F723DF6DB200A314FD /* ArticleIconSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F9F3F623DF6DB200A314FD /* ArticleIconSchemeHandler.swift */; }; + 51F9F3F923DFB16300A314FD /* UITableView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F9F3F823DFB16300A314FD /* UITableView-Extensions.swift */; }; + 51F9F3FB23DFB25700A314FD /* Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F9F3FA23DFB25700A314FD /* Animations.swift */; }; 51FA73A42332BE110090D516 /* ArticleExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA73A32332BE110090D516 /* ArticleExtractor.swift */; }; 51FA73A52332BE110090D516 /* ArticleExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA73A32332BE110090D516 /* ArticleExtractor.swift */; }; 51FA73A72332BE880090D516 /* ExtractedArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA73A62332BE880090D516 /* ExtractedArticle.swift */; }; @@ -634,6 +653,8 @@ B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; + C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */; }; + C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */; }; D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; }; D57BE6E0204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */; }; D5907D7F2004AC00005947E5 /* NSApplication+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5907D7E2004AC00005947E5 /* NSApplication+Scriptability.swift */; }; @@ -649,7 +670,7 @@ FF3ABF13232599810074C542 /* ArticleSorterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF09232599450074C542 /* ArticleSorterTests.swift */; }; FF3ABF1523259DDB0074C542 /* ArticleSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */; }; FF3ABF162325AF5D0074C542 /* ArticleSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */; }; - FFD43E412340F488009E5CA3 /* UndoAvailableAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD43E372340F320009E5CA3 /* UndoAvailableAlertController.swift */; }; + FFD43E412340F488009E5CA3 /* MarkAsReadAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD43E372340F320009E5CA3 /* MarkAsReadAlertController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1244,6 +1265,7 @@ 5127B237222B4849006D641D /* DetailKeyboardShortcuts.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = DetailKeyboardShortcuts.plist; sourceTree = ""; }; 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHeaderView.swift; sourceTree = ""; }; 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveLabel.swift; sourceTree = ""; }; + 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewSectionHeader.swift; sourceTree = ""; }; 51314617235A797400387FDC /* NetNewsWire_iOSintentextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSintentextension_target.xcconfig; sourceTree = ""; }; 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire iOS Intents Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1259,7 +1281,7 @@ 513C5CEB232571C2003D4054 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 513C5CED232571C2003D4054 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedInspectorViewController.swift; sourceTree = ""; }; - 5141E7552374A2890013FF27 /* ArticleIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleIconSchemeHandler.swift; sourceTree = ""; }; + 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailIconSchemeHandler.swift; sourceTree = ""; }; 5142192923522B5500E07E2C /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; 514219362352510100E07E2C /* ImageScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScrollView.swift; sourceTree = ""; }; 5142194A2353C1CF00E07E2C /* main_mac.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main_mac.js; sourceTree = ""; }; @@ -1292,7 +1314,7 @@ 516AE9DE2372269A007DEEAA /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = ""; }; 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerController.swift; sourceTree = ""; }; 517630032336215100E15FFF /* main.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main.js; sourceTree = ""; }; - 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewControllerWebViewProvider.swift; sourceTree = ""; }; + 517630222336657E00E15FFF /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = ""; }; 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = ""; }; 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = ""; }; 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshInterval.swift; sourceTree = ""; }; @@ -1302,6 +1324,7 @@ 5186A634235EF3A800C97195 /* VibrantLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibrantLabel.swift; sourceTree = ""; }; 518B2ED22351B3DD00400001 /* NetNewsWire-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "NetNewsWire-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 518B2EE92351B4C200400001 /* NetNewsWire_iOSTests_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSTests_target.xcconfig; sourceTree = ""; }; + 518ED21C23D0F26000E0A862 /* UIViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController-Extensions.swift"; sourceTree = ""; }; 51934CC1230F5963006127BE /* InteractiveNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveNavigationController.swift; sourceTree = ""; }; 51934CCD2310792F006127BE /* ActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityManager.swift; sourceTree = ""; }; 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTimelineFeedDelegate.swift; sourceTree = ""; }; @@ -1320,6 +1343,12 @@ 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = ""; }; 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = ""; }; 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = ""; }; + 51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 51B5C87623F22B8200032075 /* ExtensionContainers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainers.swift; sourceTree = ""; }; + 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFeedAddRequest.swift; sourceTree = ""; }; + 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainersFile.swift; sourceTree = ""; }; + 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDefaultContainer.swift; sourceTree = ""; }; + 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFeedAddRequestFile.swift; sourceTree = ""; }; 51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = ""; }; 51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; @@ -1344,6 +1373,8 @@ 51C4528B2265095F00C03939 /* AddFolderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFolderViewController.swift; sourceTree = ""; }; 51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; }; 51C452B72265178500C03939 /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = ""; }; + 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapperScriptMessageHandler.swift; sourceTree = ""; }; + 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMigrator.swift; sourceTree = ""; }; 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RefreshProgressView.xib; sourceTree = ""; }; 51CE1C0A23622006005548FC /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = ""; }; 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; @@ -1374,6 +1405,9 @@ 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem-Extensions.swift"; sourceTree = ""; }; 51F85BFA2275D85000C787DC /* Array-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array-Extensions.swift"; sourceTree = ""; }; 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleLineUILabelSizer.swift; sourceTree = ""; }; + 51F9F3F623DF6DB200A314FD /* ArticleIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleIconSchemeHandler.swift; sourceTree = ""; }; + 51F9F3F823DFB16300A314FD /* UITableView-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView-Extensions.swift"; sourceTree = ""; }; + 51F9F3FA23DFB25700A314FD /* Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animations.swift; sourceTree = ""; }; 51FA73A32332BE110090D516 /* ArticleExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractor.swift; sourceTree = ""; }; 51FA73A62332BE880090D516 /* ExtractedArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtractedArticle.swift; sourceTree = ""; }; 51FA73B62332D5F70090D516 /* ArticleExtractorButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractorButton.swift; sourceTree = ""; }; @@ -1568,6 +1602,8 @@ B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; + C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = ""; }; + C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = ""; }; D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = ""; }; D553737C20186C1F006D8857 /* Article+Scriptability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Article+Scriptability.swift"; sourceTree = ""; }; D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = ""; }; @@ -1588,7 +1624,7 @@ DD82AB09231003F6002269DF /* SharingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharingTests.swift; sourceTree = ""; }; FF3ABF09232599450074C542 /* ArticleSorterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorterTests.swift; sourceTree = ""; }; FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorter.swift; sourceTree = ""; }; - FFD43E372340F320009E5CA3 /* UndoAvailableAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UndoAvailableAlertController.swift; sourceTree = ""; }; + FFD43E372340F320009E5CA3 /* MarkAsReadAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAsReadAlertController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1776,6 +1812,7 @@ isa = PBXGroup; children = ( 513C5CE8232571C2003D4054 /* ShareViewController.swift */, + 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */, 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */, 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */, 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */, @@ -1864,6 +1901,17 @@ path = Activity; sourceTree = ""; }; + 51B5C85A23F22A7A00032075 /* CommonExtension */ = { + isa = PBXGroup; + children = ( + 51B5C87623F22B8200032075 /* ExtensionContainers.swift */, + 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */, + 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */, + 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */, + ); + path = CommonExtension; + sourceTree = ""; + }; 51C45245226506C800C03939 /* UIKit Extensions */ = { isa = PBXGroup; children = ( @@ -1881,12 +1929,16 @@ 512363372369155100951F16 /* RoundedProgressView.swift */, 51C45250226506F400C03939 /* String-Extensions.swift */, 5108F6D723763094001ABC45 /* TickMarkSlider.swift */, + C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */, 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */, 51F85BF622749FA100C787DC /* UIFont-Extensions.swift */, 51C4524E226506F400C03939 /* UIStoryboard-Extensions.swift */, + 51F9F3F823DFB16300A314FD /* UITableView-Extensions.swift */, + 518ED21C23D0F26000E0A862 /* UIViewController-Extensions.swift */, 51FFF0C3235EE8E5002762AA /* VibrantButton.swift */, 5186A634235EF3A800C97195 /* VibrantLabel.swift */, 5F323808231DF9F000706F6B /* VibrantTableViewCell.swift */, + 51F9F3FA23DFB25700A314FD /* Animations.swift */, ); path = "UIKit Extensions"; sourceTree = ""; @@ -1925,7 +1977,7 @@ 5148F4542336DB7000F8CD8B /* MasterTimelineTitleView.swift */, 5148F44A2336DB4700F8CD8B /* MasterTimelineTitleView.xib */, 51FD413A2342BD0500880194 /* MasterTimelineUnreadCountView.swift */, - FFD43E372340F320009E5CA3 /* UndoAvailableAlertController.swift */, + FFD43E372340F320009E5CA3 /* MarkAsReadAlertController.swift */, 51C4526F2265091600C03939 /* Cell */, ); path = MasterTimeline; @@ -1949,13 +2001,17 @@ 51C4527D2265092C00C03939 /* Article */ = { isa = PBXGroup; children = ( - 51C4527E2265092C00C03939 /* ArticleViewController.swift */, - 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */, 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */, + 51F9F3F623DF6DB200A314FD /* ArticleIconSchemeHandler.swift */, + 51C4527E2265092C00C03939 /* ArticleViewController.swift */, 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */, - 5142192923522B5500E07E2C /* ImageViewController.swift */, 514219362352510100E07E2C /* ImageScrollView.swift */, 518651D9235621840078E021 /* ImageTransition.swift */, + 5142192923522B5500E07E2C /* ImageViewController.swift */, + 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */, + 51AB8AB223B7F4C6008F147D /* WebViewController.swift */, + 517630222336657E00E15FFF /* WebViewProvider.swift */, + 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */, ); path = Article; sourceTree = ""; @@ -1979,7 +2035,6 @@ 51C452A822650DA100C03939 /* Article Rendering */ = { isa = PBXGroup; children = ( - 5141E7552374A2890013FF27 /* ArticleIconSchemeHandler.swift */, 849A977D1ED9EC42007D329B /* ArticleRenderer.swift */, 517630032336215100E15FFF /* main.js */, 49F40DEF2335B71000552BF4 /* newsfoot.js */, @@ -2304,6 +2359,7 @@ 84216D0222128B9D0049B9B9 /* DetailWebViewController.swift */, 84E8E0EA202F693600562D8F /* DetailWebView.swift */, 84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */, + 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */, B528F81D23333C7E00E735DD /* page.html */, 5142194A2353C1CF00E07E2C /* main_mac.js */, 848362FC2262A30800DA1D35 /* styleSheet.css */, @@ -2585,7 +2641,9 @@ 51C45255226507D200C03939 /* AppDefaults.swift */, 51E3EB3C229AB08300645299 /* ErrorHandler.swift */, 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */, + C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */, 51B62E67233186730085F949 /* IconView.swift */, + 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */, 51C4525D226508F600C03939 /* MasterFeed */, 51C4526D2265091600C03939 /* MasterTimeline */, 51C4527D2265092C00C03939 /* Article */, @@ -2595,6 +2653,7 @@ 513145F9235A55A700387FDC /* Intents */, 5183CCEB227117C70010922C /* Settings */, 51C45245226506C800C03939 /* UIKit Extensions */, + 51B5C85A23F22A7A00032075 /* CommonExtension */, 513C5CE7232571C2003D4054 /* ShareExtension */, 51314643235A7C2300387FDC /* IntentsExtension */, 84C9FC9A2262A1A900D921D6 /* Resources */, @@ -3618,8 +3677,12 @@ buildActionMask = 2147483647; files = ( 513146B3235A81A400387FDC /* AddWebFeedIntentHandler.swift in Sources */, + 51B5C8E623F4BBFA00032075 /* ExtensionFeedAddRequest.swift in Sources */, 51314705235C41FC00387FDC /* Intents.intentdefinition in Sources */, + 51B5C8E523F4BBFA00032075 /* ExtensionContainersFile.swift in Sources */, 51314668235A7E4600387FDC /* IntentHandler.swift in Sources */, + 51B5C8E423F4BBFA00032075 /* ExtensionContainers.swift in Sources */, + 51B5C8E723F4BBFA00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3628,13 +3691,17 @@ buildActionMask = 2147483647; files = ( 515D4FC123257A3200EE1167 /* FolderTreeControllerDelegate.swift in Sources */, + 51B5C8B923F368D000032075 /* ExtensionContainers.swift in Sources */, 515D4FCA23257CB500EE1167 /* Node-Extensions.swift in Sources */, 513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */, 51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */, - 51A9A5E02380C3F10033AADF /* AddWebFeedDefaultContainer.swift in Sources */, + 51B5C8BE23F37B2400032075 /* ShareDefaultContainer.swift in Sources */, + 51B5C8BA23F368D000032075 /* ExtensionContainersFile.swift in Sources */, + 51B5C8BB23F368D000032075 /* ExtensionFeedAddRequest.swift in Sources */, 51A9A5E82380CA130033AADF /* ShareFolderPickerCell.swift in Sources */, 51A9A5EF2380D63B0033AADF /* IconImage.swift in Sources */, 51A9A5ED2380D6000033AADF /* AppAssets.swift in Sources */, + 51B5C8C123F3A0DB00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3711,7 +3778,7 @@ 65ED3FE9235DEF6C0081F399 /* FaviconURLFinder.swift in Sources */, 65ED3FEA235DEF6C0081F399 /* SidebarViewController+ContextualMenus.swift in Sources */, 65ED3FEC235DEF6C0081F399 /* RSHTMLMetadata+Extension.swift in Sources */, - 518C3194237B00DA004D740F /* ArticleIconSchemeHandler.swift in Sources */, + 518C3194237B00DA004D740F /* DetailIconSchemeHandler.swift in Sources */, 65ED3FED235DEF6C0081F399 /* SendToMarsEditCommand.swift in Sources */, 65ED3FEE235DEF6C0081F399 /* UserNotificationManager.swift in Sources */, 65ED3FEF235DEF6C0081F399 /* ScriptingObjectContainer.swift in Sources */, @@ -3821,8 +3888,10 @@ 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */, 51C452A422650A2D00C03939 /* ArticleUtilities.swift in Sources */, 51EF0F79227716380050506E /* ColorHash.swift in Sources */, + 51F9F3FB23DFB25700A314FD /* Animations.swift in Sources */, 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */, B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */, + 518ED21D23D0F26000E0A862 /* UIViewController-Extensions.swift in Sources */, 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */, 51EAED96231363EF00A9EEE3 /* NonIntrinsicButton.swift in Sources */, 51C4527B2265091600C03939 /* MasterUnreadIndicatorView.swift in Sources */, @@ -3835,7 +3904,7 @@ 511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */, 51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */, 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */, - 517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */, + 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */, 51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */, 51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */, 51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */, @@ -3843,6 +3912,7 @@ 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */, 51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */, 51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */, + 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */, 516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */, 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */, 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */, @@ -3853,6 +3923,7 @@ 51314704235C41FC00387FDC /* Intents.intentdefinition in Sources */, FF3ABF162325AF5D0074C542 /* ArticleSorter.swift in Sources */, 51C4525C226508DF00C03939 /* String-Extensions.swift in Sources */, + 51F9F3F923DFB16300A314FD /* UITableView-Extensions.swift in Sources */, 51C452792265091600C03939 /* MasterTimelineTableViewCell.swift in Sources */, 51C4526B226508F600C03939 /* MasterFeedViewController.swift in Sources */, 5126EE97226CB48A00C22AFC /* SceneCoordinator.swift in Sources */, @@ -3869,10 +3940,11 @@ 51C452A022650A1900C03939 /* WebFeedIconDownloader.swift in Sources */, 51C4529E22650A1900C03939 /* ImageDownloader.swift in Sources */, 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */, + 51B5C87723F22B8200032075 /* ExtensionContainers.swift in Sources */, 51C45292226509C800C03939 /* TodayFeedDelegate.swift in Sources */, 51C452A222650A1900C03939 /* RSHTMLMetadata+Extension.swift in Sources */, 514B7D1F23219F3C00BAC947 /* AddControllerType.swift in Sources */, - 5141E7562374A2890013FF27 /* ArticleIconSchemeHandler.swift in Sources */, + 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */, 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */, 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */, 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */, @@ -3894,13 +3966,17 @@ 51F85BF722749FA100C787DC /* UIFont-Extensions.swift in Sources */, 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */, 51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */, + 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */, 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */, + 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */, 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */, 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */, + C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */, 5108F6D42375EEEF001ABC45 /* TimelinePreviewTableViewController.swift in Sources */, 84CAFCA522BC8C08007694F0 /* FetchRequestQueue.swift in Sources */, 51C4529C22650A1000C03939 /* SingleFaviconDownloader.swift in Sources */, 51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */, + 51F9F3F723DF6DB200A314FD /* ArticleIconSchemeHandler.swift in Sources */, 512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */, 51A1699F235E10D700EB091F /* AboutViewController.swift in Sources */, 51C45290226509C100C03939 /* PseudoFeed.swift in Sources */, @@ -3913,17 +3989,21 @@ 51C452AC22650FD200C03939 /* AppNotifications.swift in Sources */, 51EF0F7E2277A57D0050506E /* MasterTimelineAccessibilityCellLayout.swift in Sources */, 51A1699B235E10D700EB091F /* AccountInspectorViewController.swift in Sources */, + 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */, 51C452762265091600C03939 /* MasterTimelineViewController.swift in Sources */, 5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */, 51C452882265093600C03939 /* AddWebFeedViewController.swift in Sources */, + 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */, 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, + 51C9DE8623F09FAA003D5A6D /* AccountMigrator.swift in Sources */, 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */, 518651DA235621840078E021 /* ImageTransition.swift in Sources */, 51C266EA238C334800F53014 /* ContextMenuPreviewViewController.swift in Sources */, 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */, 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */, + C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */, 51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */, 84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */, 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */, @@ -3933,7 +4013,7 @@ 51C4529F22650A1900C03939 /* AuthorAvatarDownloader.swift in Sources */, 5108F6D22375EED2001ABC45 /* TimelineCustomizerViewController.swift in Sources */, 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */, - FFD43E412340F488009E5CA3 /* UndoAvailableAlertController.swift in Sources */, + FFD43E412340F488009E5CA3 /* MarkAsReadAlertController.swift in Sources */, 51C452A322650A1E00C03939 /* HTMLMetadataDownloader.swift in Sources */, 51C4528D2265095F00C03939 /* AddFolderViewController.swift in Sources */, 51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */, @@ -4054,7 +4134,7 @@ 848D578E21543519005FFAD5 /* PasteboardWebFeed.swift in Sources */, 5144EA2F2279FAB600D19003 /* AccountsDetailViewController.swift in Sources */, 849A97801ED9EC42007D329B /* DetailViewController.swift in Sources */, - 518C3193237B00D9004D740F /* ArticleIconSchemeHandler.swift in Sources */, + 518C3193237B00D9004D740F /* DetailIconSchemeHandler.swift in Sources */, 84C9FC6722629B9000D921D6 /* AppDelegate.swift in Sources */, 84C9FC7A22629E1200D921D6 /* AccountsTableViewBackgroundView.swift in Sources */, 84CAFCAF22BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, diff --git a/Shared/Article Rendering/ArticleRenderer.swift b/Shared/Article Rendering/ArticleRenderer.swift index 4eca6c0c2..92f0de19c 100644 --- a/Shared/Article Rendering/ArticleRenderer.swift +++ b/Shared/Article Rendering/ArticleRenderer.swift @@ -13,16 +13,15 @@ import Account struct ArticleRenderer { - typealias Rendering = (style: String, html: String) - typealias Page = (html: String, baseURL: URL) + typealias Rendering = (style: String, html: String, title: String, baseURL: String) + typealias Page = (url: URL, baseURL: URL) static var imageIconScheme = "nnwImageIcon" static var page: Page = { - let pageURL = Bundle.main.url(forResource: "page", withExtension: "html")! - let html = try! String(contentsOf: pageURL) - let baseURL = pageURL.deletingLastPathComponent() - return Page(html: html, baseURL: baseURL) + let url = Bundle.main.url(forResource: "page", withExtension: "html")! + let baseURL = url.deletingLastPathComponent() + return Page(url: url, baseURL: baseURL) }() private let article: Article? @@ -48,29 +47,29 @@ struct ArticleRenderer { // MARK: - API - static func articleHTML(article: Article, extractedArticle: ExtractedArticle? = nil, style: ArticleStyle, useImageIcon: Bool = false) -> Rendering { + static func articleHTML(article: Article, extractedArticle: ExtractedArticle? = nil, style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: article, extractedArticle: extractedArticle, style: style) - return (renderer.styleString(), renderer.articleHTML) + return (renderer.styleString(), renderer.articleHTML, renderer.title, renderer.baseURL ?? "") } static func multipleSelectionHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) - return (renderer.styleString(), renderer.multipleSelectionHTML) + return (renderer.styleString(), renderer.multipleSelectionHTML, renderer.title, renderer.baseURL ?? "") } static func loadingHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) - return (renderer.styleString(), renderer.loadingHTML) + return (renderer.styleString(), renderer.loadingHTML, renderer.title, renderer.baseURL ?? "") } static func noSelectionHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) - return (renderer.styleString(), renderer.noSelectionHTML) + return (renderer.styleString(), renderer.noSelectionHTML, renderer.title, renderer.baseURL ?? "") } static func noContentHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) - return (renderer.styleString(), renderer.noContentHTML) + return (renderer.styleString(), renderer.noContentHTML, renderer.title, renderer.baseURL ?? "") } } @@ -79,27 +78,27 @@ struct ArticleRenderer { private extension ArticleRenderer { private var articleHTML: String { - let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: articleSubstitutions(), macroStart: "[[", macroEnd: "]]") - return renderHTML(withBody: body) + let body = try! MacroProcessor.renderedText(withTemplate: template(), substitutions: articleSubstitutions()) + return body } private var multipleSelectionHTML: String { let body = "

Multiple selection

" - return renderHTML(withBody: body) + return body } private var loadingHTML: String { let body = "

Loading...

" - return renderHTML(withBody: body) + return body } private var noSelectionHTML: String { let body = "

No selection

" - return renderHTML(withBody: body) + return body } private var noContentHTML: String { - return renderHTML(withBody: "") + return "" } static var defaultStyleSheet: String = { @@ -141,7 +140,16 @@ private extension ArticleRenderer { d["title"] = title d["body"] = body - d["avatars"] = ""; + + var components = URLComponents() + components.scheme = Self.imageIconScheme + components.path = article.articleID + if let imageIconURLString = components.string { + d["avatars"] = "" + } + else { + d["avatars"] = "" + } var feedLink = "" if let feedTitle = article.webFeed?.nameForDisplay { @@ -157,7 +165,7 @@ private extension ArticleRenderer { let mediumDate = dateString(datePublished, .medium, .short) let shortDate = dateString(datePublished, .short, .short) - if dateShouldBeLink() || self.title == "", let permalink = article.url { + if let permalink = article.url { d["date_long"] = longDate.htmlByAddingLink(permalink) d["date_medium"] = mediumDate.htmlByAddingLink(permalink) d["date_short"] = shortDate.htmlByAddingLink(permalink) @@ -173,16 +181,6 @@ private extension ArticleRenderer { return d } - func dateShouldBeLink() -> Bool { - guard let permalink = article?.url else { - return false - } - guard let preferredLink = article?.preferredLink else { // Title uses preferredLink - return false - } - return permalink != preferredLink // Make date a link if it’s a different link from the title’s link - } - func byline() -> String { guard let authors = article?.authors ?? article?.webFeed?.authors, !authors.isEmpty else { return "" @@ -236,17 +234,6 @@ private extension ArticleRenderer { return dateFormatter.string(from: date) } - func renderHTML(withBody body: String) -> String { - var s = "" - if let baseURL = baseURL { - s += ("") - } - s += title.htmlBySurroundingWithTag("title") - - s += body - return s - } - } // MARK: - Article extension diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index 4ecf8eb5f..f11730264 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -8,22 +8,80 @@ function wrapFrames() { }); } -// Strip out all styling so that we have better control over layout -function stripStyles() { - document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove()); - document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => element.removeAttribute("style")); +// Strip out color and font styling + +function stripStylesFromElement(element, propertiesToStrip) { + for (name of propertiesToStrip) { + element.style.removeProperty(name); + } } -// Convert all image locations to be absolute +function stripStyles() { + document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove()); + // Removing "background" and "font" will also remove properties that would be reflected in them, e.g., "background-color" and "font-family" + document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => stripStylesFromElement(element, ["color", "background", "font"])); +} + +// Convert all Feedbin proxy images to be used as src, otherwise change image locations to be absolute if not already function convertImgSrc() { document.querySelectorAll("img").forEach(element => { - element.src = new URL(element.src, document.baseURI).href; + if (element.hasAttribute("data-canonical-src")) { + element.src = element.getAttribute("data-canonical-src") + } else if (!element.src.match(/^[a-z]+\:\/\//i)) { + element.src = new URL(element.src, document.baseURI).href; + } }); } -function reloadArticleImage() { +// Wrap tables in an overflow-x: auto; div +function wrapTables() { + var tables = document.querySelector("div.articleBody").getElementsByTagName("table"); + + for (table of tables) { + var wrapper = document.createElement("div"); + wrapper.className = "nnw-overflow"; + table.parentNode.insertBefore(wrapper, table); + wrapper.appendChild(table); + } +} + +// Remove some children (currently just spans) from pre elements to work around a strange clipping issue +var ElementUnwrapper = { + unwrapSelector: "span", + unwrapElement: function (element) { + var parent = element.parentNode; + var children = Array.from(element.childNodes); + + for (child of children) { + parent.insertBefore(child, element); + } + + parent.removeChild(element); + }, + // `elements` can be a selector string, an element, or a list of elements + unwrapAppropriateChildren: function (elements) { + if (typeof elements[Symbol.iterator] !== 'function') + elements = [elements]; + else if (typeof elements === "string") + elements = document.querySelectorAll(elements); + + for (element of elements) { + for (unwrap of element.querySelectorAll(this.unwrapSelector)) { + this.unwrapElement(unwrap); + } + + element.normalize() + } + } +}; + +function flattenPreElements() { + ElementUnwrapper.unwrapAppropriateChildren("div.articleBody td > pre"); +} + +function reloadArticleImage(imageSrc) { var image = document.getElementById("nnwImageIcon"); - image.src = "nnwImageIcon://"; + image.src = imageSrc; } function error() { @@ -32,13 +90,22 @@ function error() { function render(data, scrollY) { document.getElementsByTagName("style")[0].innerHTML = data.style; + + let title = document.getElementsByTagName("title")[0]; + title.textContent = data.title + + let base = document.getElementsByTagName("base")[0]; + base.href = data.baseURL + document.body.innerHTML = data.body; window.scrollTo(0, scrollY); wrapFrames() + wrapTables() stripStyles() convertImgSrc() - + flattenPreElements() + postRenderProcessing() } diff --git a/Shared/ArticleStyles/ArticleStyle.swift b/Shared/ArticleStyles/ArticleStyle.swift index 3f6898ff5..868745c9b 100644 --- a/Shared/ArticleStyles/ArticleStyle.swift +++ b/Shared/ArticleStyles/ArticleStyle.swift @@ -37,7 +37,7 @@ struct ArticleStyle: Equatable { self.path = path - let isFolder = FileManager.default.rs_fileIsFolder(path) + let isFolder = FileManager.default.isFolder(atPath: path) if isFolder { diff --git a/Shared/ArticleStyles/ArticleStylesManager.swift b/Shared/ArticleStyles/ArticleStylesManager.swift index 7bd161c1c..ddc23b3f8 100644 --- a/Shared/ArticleStyles/ArticleStylesManager.swift +++ b/Shared/ArticleStyles/ArticleStylesManager.swift @@ -6,7 +6,12 @@ // Copyright © 2015 Ranchero Software, LLC. All rights reserved. // -import Foundation +#if os(macOS) +import AppKit +#else +import UIKit +#endif + import RSCore let ArticleStyleNamesDidChangeNotification = "ArticleStyleNamesDidChangeNotification" @@ -24,7 +29,7 @@ private let styleSuffixes = [styleSuffix, nnwStyleSuffix, cssStyleSuffix]; final class ArticleStylesManager { static let shared = ArticleStylesManager() - private let folderPath = RSDataSubfolder(nil, stylesFolderName)! + private let folderPath = Platform.dataSubfolder(forApplication: nil, folderName: stylesFolderName)! var currentStyleName: String { get { @@ -133,8 +138,8 @@ final class ArticleStylesManager { private func allStylePaths(_ folder: String) -> [String] { - let filepaths = FileManager.default.rs_filepaths(inFolder: folder) - return filepaths.filter { fileAtPathIsStyle($0) } + let filepaths = FileManager.default.filePaths(inFolder: folder) + return filepaths?.filter { fileAtPathIsStyle($0) } ?? [] } private func fileAtPathIsStyle(_ f: String) -> Bool { @@ -154,7 +159,7 @@ private func filenameWithStyleSuffixRemoved(_ filename: String) -> String { for oneSuffix in styleSuffixes { if filename.hasSuffix(oneSuffix) { - return (filename as NSString).rs_string(byStrippingSuffix: oneSuffix, caseSensitive: false) + return filename.stripping(suffix: oneSuffix) } } @@ -174,7 +179,6 @@ private func pathIsPathForStyleName(_ styleName: String, path: String) -> Bool { } private func pathForStyleName(_ styleName: String, folder: String) -> String? { - for onePath in allStylePaths(folder) { if pathIsPathForStyleName(styleName, path: onePath) { return onePath diff --git a/Shared/Commands/SendToMarsEditCommand.swift b/Shared/Commands/SendToMarsEditCommand.swift index bcf277ac6..7b50f07fa 100644 --- a/Shared/Commands/SendToMarsEditCommand.swift +++ b/Shared/Commands/SendToMarsEditCommand.swift @@ -57,7 +57,7 @@ private extension SendToMarsEditCommand { let body = article.contentHTML ?? article.contentText ?? article.summary let authorName = article.authors?.first?.name - let sender = SendToBlogEditorApp(targetDesciptor: targetDescriptor, title: article.title, body: body, summary: article.summary, link: article.externalURL, permalink: article.url, subject: nil, creator: authorName, commentsURL: nil, guid: article.uniqueID, sourceName: article.webFeed?.nameForDisplay, sourceHomeURL: article.webFeed?.homePageURL, sourceFeedURL: article.webFeed?.url) + let sender = SendToBlogEditorApp(targetDescriptor: targetDescriptor, title: article.title, body: body, summary: article.summary, link: article.externalURL, permalink: article.url, subject: nil, creator: authorName, commentsURL: nil, guid: article.uniqueID, sourceName: article.webFeed?.nameForDisplay, sourceHomeURL: article.webFeed?.homePageURL, sourceFeedURL: article.webFeed?.url) let _ = sender.send() } diff --git a/Shared/Commands/SendToMicroBlogCommand.swift b/Shared/Commands/SendToMicroBlogCommand.swift index 58a3a2d6b..71dca639f 100644 --- a/Shared/Commands/SendToMicroBlogCommand.swift +++ b/Shared/Commands/SendToMicroBlogCommand.swift @@ -50,7 +50,7 @@ final class SendToMicroBlogCommand: SendToCommand { let s = article.attributionString + article.linkString let urlQueryDictionary = ["text": s] - guard let urlQueryString = urlQueryDictionary.urlQueryString() else { + guard let urlQueryString = urlQueryDictionary.urlQueryString else { return } guard let url = URL(string: "microblog://post?" + urlQueryString) else { diff --git a/Shared/Data/ArticleStringFormatter.swift b/Shared/Data/ArticleStringFormatter.swift index 6bd00baf3..4fb9249ae 100644 --- a/Shared/Data/ArticleStringFormatter.swift +++ b/Shared/Data/ArticleStringFormatter.swift @@ -65,8 +65,8 @@ struct ArticleStringFormatter { s = s.replacingOccurrences(of: "\r", with: "") s = s.replacingOccurrences(of: "\t", with: "") s = s.rsparser_stringByDecodingHTMLEntities() - s = s.rs_stringByTrimmingWhitespace() - s = s.rs_stringWithCollapsedWhitespace() + s = s.trimmingWhitespace + s = s.collapsingWhitespace let maxLength = 1000 if s.count < maxLength { @@ -89,9 +89,9 @@ struct ArticleStringFormatter { return cachedBody } var s = body.rsparser_stringByDecodingHTMLEntities() - s = s.rs_string(byStrippingHTML: 250) - s = s.rs_stringByTrimmingWhitespace() - s = s.rs_stringWithCollapsedWhitespace() + s = s.strippingHTML(maxCharacters: 250) + s = s.trimmingWhitespace + s = s.collapsingWhitespace if s == "Comments" { // Hacker News. s = "" } @@ -100,7 +100,7 @@ struct ArticleStringFormatter { } static func dateString(_ date: Date) -> String { - if NSCalendar.rs_dateIsToday(date) { + if Calendar.dateIsToday(date) { return timeFormatter.string(from: date) } return dateFormatter.string(from: date) diff --git a/Shared/Data/ArticleUtilities.swift b/Shared/Data/ArticleUtilities.swift index 997cb0ded..c5d2421b1 100644 --- a/Shared/Data/ArticleUtilities.swift +++ b/Shared/Data/ArticleUtilities.swift @@ -63,6 +63,25 @@ extension Article { var logicalDatePublished: Date { return datePublished ?? dateModified ?? status.dateArrived } + + var isAvailableToMarkUnread: Bool { + guard let markUnreadWindow = account?.behaviors.compactMap( { behavior -> Int? in + switch behavior { + case .disallowMarkAsUnreadAfterPeriod(let days): + return days + default: + return nil + } + }).first else { + return true + } + + if logicalDatePublished.byAdding(days: markUnreadWindow) > Date() { + return true + } else { + return false + } + } func iconImage() -> IconImage? { if let authors = authors, authors.count == 1, let author = authors.first { diff --git a/Shared/Data/CacheCleaner.swift b/Shared/Data/CacheCleaner.swift index 748f13aa9..9f36787cd 100644 --- a/Shared/Data/CacheCleaner.swift +++ b/Shared/Data/CacheCleaner.swift @@ -14,9 +14,14 @@ struct CacheCleaner { static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CacheCleaner") static func purgeIfNecessary() { + + guard let flushDate = AppDefaults.lastImageCacheFlushDate else { + AppDefaults.lastImageCacheFlushDate = Date() + return + } // If the image disk cache hasn't been flushed for 3 days and the network is available, delete it - if let flushDate = AppDefaults.lastImageCacheFlushDate, flushDate.addingTimeInterval(3600*24*3) < Date() { + if flushDate.addingTimeInterval(3600 * 24 * 3) < Date() { if let reachability = try? Reachability(hostname: "apple.com") { if reachability.connection != .unavailable { diff --git a/Shared/Exporters/OPMLExporter.swift b/Shared/Exporters/OPMLExporter.swift index 75dde22d5..cba135a17 100644 --- a/Shared/Exporters/OPMLExporter.swift +++ b/Shared/Exporters/OPMLExporter.swift @@ -14,7 +14,7 @@ struct OPMLExporter { static func OPMLString(with account: Account, title: String) -> String { - let escapedTitle = title.rs_stringByEscapingSpecialXMLCharacters() + let escapedTitle = title.escapingSpecialXMLCharacters let openingText = """ @@ -27,7 +27,7 @@ struct OPMLExporter { """ - let middleText = account.OPMLString(indentLevel: 0, strictConformance: true) + let middleText = account.OPMLString(indentLevel: 0) let closingText = """ diff --git a/Shared/Extensions/IconImage.swift b/Shared/Extensions/IconImage.swift index 9d82daad1..8d0a6c8b2 100644 --- a/Shared/Extensions/IconImage.swift +++ b/Shared/Extensions/IconImage.swift @@ -6,7 +6,12 @@ // Copyright © 2019 Ranchero Software. All rights reserved. // -import Foundation +#if os(macOS) +import AppKit +#else +import UIKit +#endif + import RSCore final class IconImage { @@ -52,15 +57,22 @@ extension CGImage { let r = ptr[i] let g = ptr[i + 1] let b = ptr[i + 2] + let a = ptr[i + 3] let luminance = (0.299 * Double(r) + 0.587 * Double(g) + 0.114 * Double(b)) - totalLuminance += luminance - pixelCount += 1 + if Double(a) > 0 { + totalLuminance += luminance + pixelCount += 1 + } } let avgLuminance = totalLuminance / Double(pixelCount) - return avgLuminance < 37.5 + if totalLuminance == 0 { + return true + } else { + return avgLuminance < 40 + } } } diff --git a/Shared/Extensions/RSImage-Extensions.swift b/Shared/Extensions/RSImage-Extensions.swift index 505079fbb..30a3d570b 100644 --- a/Shared/Extensions/RSImage-Extensions.swift +++ b/Shared/Extensions/RSImage-Extensions.swift @@ -6,7 +6,12 @@ // Copyright © 2019 Ranchero Software. All rights reserved. // -import Foundation +#if os(macOS) +import AppKit +#else +import UIKit +#endif + import RSCore extension RSImage { diff --git a/Shared/Favicons/FaviconDownloader.swift b/Shared/Favicons/FaviconDownloader.swift index a49ad6dfd..25c83bcc1 100644 --- a/Shared/Favicons/FaviconDownloader.swift +++ b/Shared/Favicons/FaviconDownloader.swift @@ -7,6 +7,7 @@ // import Foundation +import CoreServices import Articles import Account import RSCore @@ -24,6 +25,7 @@ final class FaviconDownloader { private let diskCache: BinaryDiskCache private var singleFaviconDownloaderCache = [String: SingleFaviconDownloader]() // faviconURL: SingleFaviconDownloader private var remainingFaviconURLs = [String: ArraySlice]() // homePageURL: array of faviconURLs that haven't been checked yet + private var currentHomePageHasOnlyFaviconICO = false private var homePageToFaviconURLCache = [String: String]() //homePageURL: faviconURL private var homePageToFaviconURLCachePath: String @@ -59,6 +61,8 @@ final class FaviconDownloader { loadHomePageToFaviconURLCache() loadHomePageURLsWithNoFaviconURLCache() + FaviconURLFinder.ignoredTypes = [kUTTypeScalableVectorGraphics as String] + NotificationCenter.default.addObserver(self, selector: #selector(didLoadFavicon(_:)), name: .DidLoadFavicon, object: nil) } @@ -114,7 +118,7 @@ final class FaviconDownloader { func favicon(withHomePageURL homePageURL: String) -> IconImage? { - let url = homePageURL.rs_normalizedURL() + let url = homePageURL.normalizedURL if let url = URL(string: homePageURL) { if url.host == "nnw.ranchero.com" { @@ -131,20 +135,15 @@ final class FaviconDownloader { } findFaviconURLs(with: url) { (faviconURLs) in - var hasIcons = false - if let faviconURLs = faviconURLs { + // If the site explicitly specifies favicon.ico, it will appear twice. + self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1 + if let firstIconURL = faviconURLs.first { - hasIcons = true let _ = self.favicon(with: firstIconURL, homePageURL: url) self.remainingFaviconURLs[url] = faviconURLs.dropFirst() } } - - if (!hasIcons) { - self.homePageURLsWithNoFaviconURLCache.insert(url) - self.homePageURLsWithNoFaviconURLCacheDirty = true - } } return nil @@ -167,6 +166,11 @@ final class FaviconDownloader { remainingFaviconURLs[homePageURL] = faviconURLs.dropFirst(); } else { remainingFaviconURLs[homePageURL] = nil + + if currentHomePageHasOnlyFaviconICO { + self.homePageURLsWithNoFaviconURLCache.insert(homePageURL) + self.homePageURLsWithNoFaviconURLCacheDirty = true + } } } return @@ -174,11 +178,9 @@ final class FaviconDownloader { remainingFaviconURLs[homePageURL] = nil - if let url = singleFaviconDownloader.homePageURL { - if self.homePageToFaviconURLCache[url] == nil { - self.homePageToFaviconURLCache[url] = singleFaviconDownloader.faviconURL - self.homePageToFaviconURLCacheDirty = true - } + if self.homePageToFaviconURLCache[homePageURL] == nil { + self.homePageToFaviconURLCache[homePageURL] = singleFaviconDownloader.faviconURL + self.homePageToFaviconURLCacheDirty = true } postFaviconDidBecomeAvailableNotification(singleFaviconDownloader.faviconURL) @@ -208,22 +210,23 @@ private extension FaviconDownloader { return } - FaviconURLFinder.findFaviconURLs(homePageURL) { (faviconURLs) in + FaviconURLFinder.findFaviconURLs(with: homePageURL) { (faviconURLs) in + guard var faviconURLs = faviconURLs else { + completion(nil) + return + } + var defaultFaviconURL: String? = nil if let scheme = url.scheme, let host = url.host { defaultFaviconURL = "\(scheme)://\(host)/favicon.ico".lowercased(with: FaviconDownloader.localeForLowercasing) } - if var faviconURLs = faviconURLs { - if let defaultFaviconURL = defaultFaviconURL { - faviconURLs.append(defaultFaviconURL) - } - completion(faviconURLs) - return + if let defaultFaviconURL = defaultFaviconURL { + faviconURLs.append(defaultFaviconURL) } - completion(defaultFaviconURL != nil ? [defaultFaviconURL!] : nil) + completion(faviconURLs) } } diff --git a/Shared/Favicons/FaviconURLFinder.swift b/Shared/Favicons/FaviconURLFinder.swift index 654dd1f3b..cc429e4c6 100644 --- a/Shared/Favicons/FaviconURLFinder.swift +++ b/Shared/Favicons/FaviconURLFinder.swift @@ -7,21 +7,64 @@ // import Foundation +import CoreServices import RSParser // The favicon URLs may be specified in the head section of the home page. struct FaviconURLFinder { - static func findFaviconURLs(_ homePageURL: String, _ completion: @escaping ([String]?) -> Void) { + private static var ignoredMimeTypes = [String]() + private static var ignoredExtensions = [String]() + + /// Uniform types to ignore when finding favicon URLs. + static var ignoredTypes: [String]? { + didSet { + guard let ignoredTypes = ignoredTypes else { + return + } + + for type in ignoredTypes { + if let mimeTypes = UTTypeCopyAllTagsWithClass(type as CFString, kUTTagClassMIMEType)?.takeRetainedValue() { + ignoredMimeTypes.append(contentsOf: mimeTypes as! [String]) + } + if let extensions = UTTypeCopyAllTagsWithClass(type as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() { + ignoredExtensions.append(contentsOf: extensions as! [String]) + } + } + } + } + + /// Finds favicon URLs in a web page. + /// - Parameters: + /// - homePageURL: The page to search. + /// - completion: A closure called when the links have been found. + /// - urls: An array of favicon URLs as strings. + static func findFaviconURLs(with homePageURL: String, _ completion: @escaping (_ urls: [String]?) -> Void) { guard let _ = URL(string: homePageURL) else { completion(nil) return } + // If the favicon has an explicit type, check that for an ignored type; otherwise, check the file extension. HTMLMetadataDownloader.downloadMetadata(for: homePageURL) { (htmlMetadata) in - completion(htmlMetadata?.faviconLinks) + let faviconURLs = htmlMetadata?.favicons.compactMap({ (favicon) -> String? in + if let type = favicon.type { + if ignoredMimeTypes.contains(type) { + return nil + } + } + else { + if let urlString = favicon.urlString, let url = URL(string: urlString), ignoredExtensions.contains(url.pathExtension) { + return nil + } + } + + return favicon.urlString + }) + + completion(faviconURLs) } } } diff --git a/Shared/Favicons/SingleFaviconDownloader.swift b/Shared/Favicons/SingleFaviconDownloader.swift index d4b91283f..8a37486f3 100644 --- a/Shared/Favicons/SingleFaviconDownloader.swift +++ b/Shared/Favicons/SingleFaviconDownloader.swift @@ -34,7 +34,7 @@ final class SingleFaviconDownloader { private let queue: DispatchQueue private var diskKey: String { - return (faviconURL as NSString).rs_md5Hash() + return faviconURL.md5String } init(faviconURL: String, homePageURL: String?, diskCache: BinaryDiskCache, queue: DispatchQueue) { @@ -103,7 +103,7 @@ private extension SingleFaviconDownloader { queue.async { if let data = self.diskCache[self.diskKey], !data.isEmpty { - RSImage.rs_image(with: data, imageResultBlock: completion) + RSImage.image(with: data, imageResultBlock: completion) return } @@ -138,7 +138,7 @@ private extension SingleFaviconDownloader { if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil { self.saveToDisk(data) - RSImage.rs_image(with: data, imageResultBlock: completion) + RSImage.image(with: data, imageResultBlock: completion) return } diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index 289ef74fe..7ea33c0ac 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -128,7 +128,7 @@ private extension ImageDownloader { func diskKey(_ url: String) -> String { - return (url as NSString).rs_md5Hash() + return url.md5String } func postImageDidBecomeAvailableNotification(_ url: String) { diff --git a/Shared/Images/RSHTMLMetadata+Extension.swift b/Shared/Images/RSHTMLMetadata+Extension.swift index cf5dd8ba7..82580ab37 100644 --- a/Shared/Images/RSHTMLMetadata+Extension.swift +++ b/Shared/Images/RSHTMLMetadata+Extension.swift @@ -12,8 +12,9 @@ import RSParser extension RSHTMLMetadata { func largestOpenGraphImageURL() -> String? { + let openGraphImages = openGraphProperties.images - guard let openGraphImages = openGraphProperties?.images, !openGraphImages.isEmpty else { + guard !openGraphImages.isEmpty else { return nil } @@ -47,7 +48,9 @@ extension RSHTMLMetadata { func largestAppleTouchIcon() -> String? { - guard let icons = appleTouchIcons, !icons.isEmpty else { + let icons = appleTouchIcons + + guard !icons.isEmpty else { return nil } diff --git a/Shared/SmartFeeds/PseudoFeed.swift b/Shared/SmartFeeds/PseudoFeed.swift index 8a9c7769d..5c0dc0660 100644 --- a/Shared/SmartFeeds/PseudoFeed.swift +++ b/Shared/SmartFeeds/PseudoFeed.swift @@ -29,7 +29,7 @@ extension PseudoFeed { } #else -import Foundation +import UIKit import Articles import Account import RSCore diff --git a/Shared/Timeline/ArticleArray.swift b/Shared/Timeline/ArticleArray.swift index 60394f084..36e53940f 100644 --- a/Shared/Timeline/ArticleArray.swift +++ b/Shared/Timeline/ArticleArray.swift @@ -109,5 +109,30 @@ extension Array where Element == Article { } return true } + + func articlesAbove(article: Article) -> [Article] { + guard let position = firstIndex(of: article) else { + return [] + } + + let articlesAbove = self[.. [Article] { + guard let position = firstIndex(of: article) else { + return [] + } + + var articlesBelow = Array(self[position...]) + + guard !articlesBelow.isEmpty else { + return [] + } + + articlesBelow.removeFirst() + + return articlesBelow + } } diff --git a/Shared/Timeline/FetchRequestQueue.swift b/Shared/Timeline/FetchRequestQueue.swift index 729813f35..4fd9ff093 100644 --- a/Shared/Timeline/FetchRequestQueue.swift +++ b/Shared/Timeline/FetchRequestQueue.swift @@ -14,6 +14,13 @@ final class FetchRequestQueue { private var pendingRequests = [FetchRequestOperation]() private var currentRequest: FetchRequestOperation? = nil + + var isAnyCurrentRequest: Bool { + if let currentRequest = currentRequest { + return !currentRequest.isCanceled + } + return false + } func cancelAllRequests() { precondition(Thread.isMainThread) diff --git a/Shared/UserNotifications/UserNotificationManager.swift b/Shared/UserNotifications/UserNotificationManager.swift index 04505b66f..2f37a2438 100644 --- a/Shared/UserNotifications/UserNotificationManager.swift +++ b/Shared/UserNotifications/UserNotificationManager.swift @@ -32,10 +32,10 @@ final class UserNotificationManager: NSObject { } @objc func statusesDidChange(_ note: Notification) { - guard let articles = note.userInfo?[Account.UserInfoKey.articles] as? Set
else { + guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set, !articleIDs.isEmpty else { return } - let identifiers = articles.filter({ $0.status.read }).map { "articleID:\($0.articleID)" } + let identifiers = articleIDs.map { "articleID:\($0)" } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) } diff --git a/Technotes/CodingGuidelines.md b/Technotes/CodingGuidelines.md index 80e623299..e7c11cdea 100644 --- a/Technotes/CodingGuidelines.md +++ b/Technotes/CodingGuidelines.md @@ -36,8 +36,6 @@ Functions should tend to be small. One-liners are a-okay, especially when the fu We mostly avoid Swift generics, since generics is an advanced feature that can be relatively hard to understand. We *do* use them, though, when appropriate. -It’s totally okay to use the magic `error` variable when catching errors. In accessors, use of the magic `oldValue` and `newValue` is expected when you need the old or new value. - We use assertions and preconditions (assertions are hit only when running a debug build; preconditions will crash a release build). We also allow force-unwrapping of optionals as a shorthand for a precondition failure, though these should be used sparingly. Extensions, including private extensions, are used — though we take care not to extend Foundation and AppKit objects too much, lest we end up with our own Cocoa dialect. @@ -104,7 +102,7 @@ Don’t fight the built-in frameworks and don’t try to hide them. Let’s not NetNewsWire is layered into frameworks. There’s an app level and a bunch of frameworks below that. Each framework has its own reason for being. Dependencies between frameworks should be as minimal as possible, but those dependencies do exist. -Some frameworks are not permitted to add dependencies, and should be treated as at the bottom of the cake: RSCore, RSWeb, RSDatabase, RSParser, and RSTree. This simplifies things for us, and makes it easier for us and other people to use these frameworks in other apps. +Some frameworks are not permitted to add dependencies, and should be treated as at the bottom of the cake: RSCore, RSWeb, RSDatabase, RSParser, RSTree, and DB5. This simplifies things for us, and makes it easier for us and other people to use these frameworks in other apps. ### User Interface @@ -112,6 +110,8 @@ Stick to stock elements, since this tends to eliminate bugs and future churn. Th Storyboards are preferred to xibs — except when the problem is xib-sized. +Use DB5 where parameters (sizes, colors, etc.) are needed. + Auto layout is used everywhere except in table and outline view cells, where performance is critical. Stack views are not allowed in table and outline view cells, but they can be useful elsewhere. However, care must be taken that performance (of window resizing, for instance) is not affected. When it is, don’t use a stack view. diff --git a/Tests/NetNewsWireTests/ScriptingTests/NSAppleEventDescriptor+UserRecordFields.swift b/Tests/NetNewsWireTests/ScriptingTests/NSAppleEventDescriptor+UserRecordFields.swift index f65467310..372d9923f 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/NSAppleEventDescriptor+UserRecordFields.swift +++ b/Tests/NetNewsWireTests/ScriptingTests/NSAppleEventDescriptor+UserRecordFields.swift @@ -22,7 +22,7 @@ extension NSAppleEventDescriptor { print ("error: usrfDictionary() expected input to be a record") return [:] } - guard let usrfList = self.forKeyword("usrf".fourCharCode()) else { + guard let usrfList = self.forKeyword("usrf".fourCharCode) else { print ("error: usrfDictionary() couldn't find usrf") return [:] } diff --git a/iOS/Account/FeedbinAccountViewController.swift b/iOS/Account/FeedbinAccountViewController.swift index 45fcb8237..695ee2a1c 100644 --- a/iOS/Account/FeedbinAccountViewController.swift +++ b/iOS/Account/FeedbinAccountViewController.swift @@ -31,10 +31,11 @@ class FeedbinAccountViewController: UITableViewController { if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) + actionButton.isEnabled = true emailTextField.text = credentials.username passwordTextField.text = credentials.secret } else { - actionButton.setTitle(NSLocalizedString("Add Account", comment: "Update Credentials"), for: .normal) + actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) } NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField) diff --git a/iOS/AccountMigrator.swift b/iOS/AccountMigrator.swift new file mode 100644 index 000000000..344513917 --- /dev/null +++ b/iOS/AccountMigrator.swift @@ -0,0 +1,24 @@ +// +// AccountMigrator.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/9/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation + +struct AccountMigrator { + + static func migrate() { + let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String + let containerAccountsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + let containerAccountsFolder = containerAccountsURL!.appendingPathComponent("Accounts") + + let documentAccountURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let documentAccountsFolder = documentAccountURL.appendingPathComponent("Accounts") + + try? FileManager.default.moveItem(at: containerAccountsFolder, to: documentAccountsFolder) + } + +} diff --git a/iOS/Add/Add.storyboard b/iOS/Add/Add.storyboard index d59f560c7..54d418e17 100644 --- a/iOS/Add/Add.storyboard +++ b/iOS/Add/Add.storyboard @@ -68,13 +68,13 @@