Initial implementation FeedlySyncStrategy and basic usability improvements to allow for downloading and reading articles without crashing.

This commit is contained in:
Kiel Gillard 2019-09-19 12:56:43 +10:00
parent 5e48c45d78
commit 1d965142d9
19 changed files with 1138 additions and 6 deletions

View File

@ -73,7 +73,23 @@
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 */; };
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */; };
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */; };
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */; };
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */; };
9E1D15532334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */; };
9E1D1555233431A600F4944C /* FeedlySyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D1554233431A600F4944C /* FeedlySyncOperation.swift */; };
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */; };
9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */; };
9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */; };
9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */; };
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */; };
9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */; };
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */; };
9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */; };
9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC625233318400085D7C9 /* FeedlyStream.swift */; };
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */; };
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */; };
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 */; };
@ -194,7 +210,23 @@
84D09622217418DC00D77525 /* FeedbinTagging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTagging.swift; sourceTree = "<group>"; };
84EAC4812148CC6300F154AB /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleFetcher.swift; sourceTree = "<group>"; };
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCreateFeedsForCollectionFoldersOperation.swift; sourceTree = "<group>"; };
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncStrategy.swift; sourceTree = "<group>"; };
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionsOperation.swift; sourceTree = "<group>"; };
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyMirrorCollectionsAsFoldersOperation.swift; sourceTree = "<group>"; };
9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetCollectionStreamOperation.swift; sourceTree = "<group>"; };
9E1D1554233431A600F4944C /* FeedlySyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlySyncOperation.swift; sourceTree = "<group>"; };
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRequestStreamsOperation.swift; sourceTree = "<group>"; };
9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyGetStreamParsedItemsOperation.swift; sourceTree = "<group>"; };
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrganiseParsedItemsByFeedOperation.swift; sourceTree = "<group>"; };
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyUpdateAccountFeedsWithItemsOperation.swift; sourceTree = "<group>"; };
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegate.swift; sourceTree = "<group>"; };
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCollection.swift; sourceTree = "<group>"; };
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyFeed.swift; sourceTree = "<group>"; };
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyEntry.swift; sourceTree = "<group>"; };
9EAEC625233318400085D7C9 /* FeedlyStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyStream.swift; sourceTree = "<group>"; };
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyCategory.swift; sourceTree = "<group>"; };
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOrigin.swift; sourceTree = "<group>"; };
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = "<group>"; };
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = "<group>"; };
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = "<group>"; };
@ -406,6 +438,22 @@
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */,
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */,
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */,
9E1D1554233431A600F4944C /* FeedlySyncOperation.swift */,
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */,
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */,
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */,
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */,
9E1D15522334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift */,
9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */,
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */,
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */,
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */,
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */,
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */,
9EAEC625233318400085D7C9 /* FeedlyStream.swift */,
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */,
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */,
);
path = Feedly;
sourceTree = SOURCE_ROOT;
@ -595,11 +643,16 @@
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */,
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */,
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */,
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */,
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
9E1D15532334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift in Sources */,
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */,
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */,
@ -608,25 +661,35 @@
515E4EB72324FF8C0057B0E7 /* Credentials.swift in Sources */,
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */,
5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */,
9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */,
844B297D2106C7EC004020B3 /* Feed.swift in Sources */,
9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */,
9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */,
515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */,
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */,
84B2D4D02238CD8A00498ADA /* FeedMetadata.swift in Sources */,
9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */,
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */,
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */,
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */,
9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */,
9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */,
9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */,
51E3EB41229AF61B00645299 /* AccountError.swift in Sources */,
9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */,
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */,
552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */,
552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */,
5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */,
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */,
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */,
@ -635,6 +698,7 @@
846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */,
515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */,
844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */,
9E1D1555233431A600F4944C /* FeedlySyncOperation.swift in Sources */,
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */,
84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */,
);

View File

@ -16,6 +16,11 @@ final class FeedlyAPICaller {
case cloud
static var `default`: API {
// https://developer.feedly.com/v3/developer/
if let token = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !token.isEmpty {
return .cloud
}
return .sandbox
}
@ -47,6 +52,136 @@ final class FeedlyAPICaller {
var server: String? {
return baseUrlComponents.host
}
func getCollections(completionHandler: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/collections"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
// URLSession.shared.dataTask(with: request) { (data, response, error) in
// print(String(data: data!, encoding: .utf8))
// }.resume()
//
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
func getStream(for collection: FeedlyCollection, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/streams/contents"
components.queryItems = [
URLQueryItem(name: "streamId", value: collection.id)
]
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
// URLSession.shared.dataTask(with: request) { (data, response, error) in
// let obj = try! JSONSerialization.jsonObject(with: data!, options: .allowFragments)
// let data = try! JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted)
// print(String(data: data, encoding: .utf8)!)
// }.resume()
transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (_, collections)):
if let response = collections {
completionHandler(.success(response))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
func markAsRead(articleIds: [String], completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/markers"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
let json: [String: Any] = [
"action": "markAsRead",
"type": "entries",
"entryIds": articleIds
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
} catch {
return DispatchQueue.main.async {
completionHandler(.failure(error))
}
}
// URLSession.shared.dataTask(with: request) { (data, response, error) in
// let obj = try! JSONSerialization.jsonObject(with: data!, options: .allowFragments)
// let data = try! JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted)
// print(String(data: data, encoding: .utf8)!)
// }.resume()
transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (httpResponse, _)):
if httpResponse.statusCode == 200 {
completionHandler(.success(()))
} else {
// tempror
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
}
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {

View File

@ -30,7 +30,16 @@ final class FeedlyAccountDelegate: AccountDelegate {
return caller.server
}
var credentials: Credentials?
var credentials: Credentials? {
didSet {
// https://developer.feedly.com/v3/developer/
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
caller.credentials = Credentials(type: .oauthAccessToken, username: "", secret: devToken)
} else {
caller.credentials = credentials
}
}
}
var accountMetadata: AccountMetadata?
@ -38,6 +47,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
private let database: SyncDatabase
private let caller: FeedlyAPICaller
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
@ -70,16 +80,27 @@ final class FeedlyAccountDelegate: AccountDelegate {
// MARK: Account API
private var syncStrategy: FeedlySyncStrategy?
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
fatalError()
let date = Date()
let log = self.log
let progress = refreshProgress
progress.addToNumberOfTasksAndRemaining(1)
syncStrategy?.startSync { result in
os_log(.debug, log: log, "Sync took %.3f seconds", -date.timeIntervalSinceNow)
progress.completeTask()
}
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
fatalError()
os_log(.debug, log: log, "*** SKIPPING SEND ARTICLE STATUS ***")
completion()
}
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
fatalError()
os_log(.debug, log: log, "*** SKIPPING REFRESH ARTICLE STATUS ***")
completion()
}
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
@ -127,12 +148,38 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
fatalError()
let log = self.log
switch statusKey {
case .read:
let ids = articles.map { $0.articleID }
caller.markAsRead(articleIds: ids) { result in
switch result {
case .success:
account.update(articles, statusKey: statusKey, flag: flag)
case .failure(let error):
os_log(.debug, log: log, "*** SKIPPING MARKING ARTICLES READ: %@ %@ ***", error as NSError, ids)
}
}
default:
os_log(.debug, log: log, "*** SKIPPING STATUS UPDATE FOR ARTICLES: %@ ***", articles)
}
return nil
}
func accountDidInitialize(_ account: Account) {
// accountMetadata = account.metadata
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
syncStrategy = FeedlySyncStrategy(account: account, caller: caller, log: log)
//TODO: Figure out how other accounts get refreshed automatically.
refreshAll(for: account) { result in
print("sync after initialise did complete")
}
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {

View File

@ -0,0 +1,14 @@
//
// FeedlyCategory.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyCategory: Decodable {
var label: String
var id: String
}

View File

@ -0,0 +1,15 @@
//
// FeedlyCollection.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyCollection: Decodable {
var feeds: [FeedlyFeed]
var label: String
var id: String
}

View File

@ -0,0 +1,81 @@
//
// FeedlyCreateFeedsForCollectionFoldersOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
/// Single responsibility is to accurately reflect Collections and their Feeds as Folders and their Feeds.
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlySyncOperation {
let account: Account
let collectionsAndFoldersProvider: FeedlyCollectionsAndFoldersProviding
let log: OSLog
init(account: Account, collectionsAndFoldersProvider: FeedlyCollectionsAndFoldersProviding, log: OSLog) {
self.collectionsAndFoldersProvider = collectionsAndFoldersProvider
self.account = account
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
var localFeeds = account.flattenedFeeds()
let feedsBefore = localFeeds
let pairs = collectionsAndFoldersProvider.collectionsAndFolders
let feedsAndFolders = pairs
.compactMap { ($0.0.feeds, $0.1) }
.map({ (collectionFeeds, folder) -> [(FeedlyFeed, Folder)] in
return collectionFeeds.map { feed -> (FeedlyFeed, Folder) in
return (feed, folder) // pairs a folder for every feed in parallel
}
})
.flatMap { $0 }
.compactMap { (collectionFeed, folder) -> (Feed, Folder) in
// find an existing feed
for feed in localFeeds {
if feed.feedID == collectionFeed.feedId {
return (feed, folder)
}
}
// no exsiting feed, create a new one
let url = collectionFeed.id
let metadata = FeedMetadata(feedID: url)
// TODO: More metadata
metadata.name = collectionFeed.title
let feed = Feed(account: account, url: url, metadata: metadata)
// So the same feed isn't created more than once.
localFeeds.insert(feed)
return (feed, folder)
}
os_log(.debug, log: log, "Processing %i feeds.", feedsAndFolders.count)
feedsAndFolders.forEach { (feed, folder) in
if !folder.has(feed) {
folder.addFeed(feed)
}
}
let feedsAfter = Set(feedsAndFolders.map { $0.0 })
let feedsWithoutCollections = feedsBefore.subtracting(feedsAfter)
for unmatched in feedsWithoutCollections {
account.removeFeed(unmatched)
}
if !feedsWithoutCollections.isEmpty {
os_log(.debug, log: log, "Removed %i feeds", feedsWithoutCollections.count)
}
}
}

View File

@ -0,0 +1,87 @@
//
// FeedlyEntry.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
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
/// the articles title. This string does not contain any HTML markup.
var title: String?
struct Content: Codable {
var content: String?
var 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?
/// content object the article summary. See the content object above.
var summary: Content?
/// the authors name
var author: String?
// /// the immutable timestamp, in ms, when this article was processed by the feedly Cloud servers.
// var crawled: Date
//
// // the timestamp, in ms, when this article was re-processed and updated by the feedly Cloud servers.
// var 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 feeds website.
var origin: FeedlyOrigin?
//
// /// 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: [Link]?
//
// // 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 feeds 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?
//
// /// 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: [Tag]?
//
/// 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: [Link]?
//
// /// 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.
}

View File

@ -0,0 +1,16 @@
//
// FeedlyFeed.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyFeed: Decodable {
var feedId: String
var id: String
var title: String
var updated: Date?
}

View File

@ -0,0 +1,57 @@
//
// FeedlyGetCollectionStreamOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyCollectionStreamProviding: class {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
}
/// Single responsibility is to get the stream content of a Collection from Feedly.
final class FeedlyGetCollectionStreamOperation: FeedlySyncOperation, FeedlyCollectionStreamProviding {
private(set) var collection: FeedlyCollection
var stream: FeedlyStream {
guard let stream = storedStream else {
// TODO: this is probably more error prone than it seems!
fatalError("\(type(of: self)) has been told to finish too early or a dependency is ignoring cancellation.")
}
return stream
}
private var storedStream: FeedlyStream?
let account: Account
let caller: FeedlyAPICaller
init(account: Account, collection: FeedlyCollection, caller: FeedlyAPICaller) {
self.account = account
self.collection = collection
self.caller = caller
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
//TODO: Use account metadata to get articles newer than some date.
caller.getStream(for: collection) { result in
switch result {
case .success(let stream):
self.storedStream = stream
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -0,0 +1,43 @@
//
// FeedlyGetCollectionsOperation.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyCollectionProviding: class {
var collections: [FeedlyCollection] { get }
}
/// Single responsibility is to get Collections from Feedly.
final class FeedlyGetCollectionsOperation: FeedlySyncOperation, FeedlyCollectionProviding {
let caller: FeedlyAPICaller
private(set) var collections = [FeedlyCollection]()
init(caller: FeedlyAPICaller) {
self.caller = caller
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
caller.getCollections { result in
switch result {
case .success(let collections):
self.collections = collections
self.didFinish()
case .failure(let error):
self.didFinish(error)
}
}
}
}

View File

@ -0,0 +1,77 @@
//
// FeedlyGetStreamParsedItemsOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSParser
import os.log
protocol FeedlyStreamParsedItemsProviding: class {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
var parsedItems: [ParsedItem] { get }
}
/// Single responsibility is to model articles as ParsedItems for entries in a Collection's stream from Feedly.
final class FeedlyGetStreamParsedItemsOperation: FeedlySyncOperation, FeedlyStreamParsedItemsProviding {
private let account: Account
private let caller: FeedlyAPICaller
private let collectionStreamProvider: FeedlyCollectionStreamProviding
private let log: OSLog
var collection: FeedlyCollection {
return collectionStreamProvider.collection
}
var stream: FeedlyStream {
return collectionStreamProvider.stream
}
private(set) var parsedItems = [ParsedItem]()
init(account: Account, collectionStreamProvider: FeedlyCollectionStreamProviding, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.collectionStreamProvider = collectionStreamProvider
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
parsedItems = stream.items.compactMap { entry -> ParsedItem? in
guard let origin = entry.origin else {
// Assertion might be too heavy handed here as our understanding of the data quality from Feedly grows.
print("Entry has no origin and no way for us to figure out which feed it should belong to: \(entry)")
return nil
}
// TODO: Sensible values here.
let parsed = ParsedItem(syncServiceID: entry.id,
uniqueID: entry.id,
feedURL: origin.streamId,
url: nil,
externalURL: origin.htmlUrl,
title: entry.title,
contentHTML: entry.content?.content,
contentText: nil, // Seems there is no corresponding field in the JSON, so we might have to derive a value.
summary: nil,
imageURL: nil,
bannerImageURL: nil,
datePublished: entry.published,
dateModified: entry.updated,
authors: nil,
tags: nil,
attachments: nil)
return parsed
}
os_log(.debug, log: log, "Parsed %i items of %i entries for %@", parsedItems.count, stream.items.count, collection.label)
}
}

View File

@ -0,0 +1,62 @@
//
// FeedlyMirrorCollectionsAsFoldersOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlyCollectionsAndFoldersProviding: class {
var collectionsAndFolders: [(FeedlyCollection, Folder)] { get }
}
/// Single responsibility is accurately reflect Collections from Feedly as Folders.
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlySyncOperation, FeedlyCollectionsAndFoldersProviding {
let caller: FeedlyAPICaller
let account: Account
let collectionsProvider: FeedlyCollectionProviding
private(set) var collectionsAndFolders = [(FeedlyCollection, Folder)]()
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller) {
self.collectionsProvider = collectionsProvider
self.account = account
self.caller = caller
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
let localFolders = account.folders ?? Set()
let collections = collectionsProvider.collections
let pairs = collections.compactMap { collection -> (FeedlyCollection, Folder)? in
for folder in localFolders {
if folder.name == collection.label {
return (collection, folder)
}
}
guard let newFolder = account.ensureFolder(with: collection.label) else {
assertionFailure("Try debugging why a folder could not be created.")
return nil
}
return (collection, newFolder)
}
collectionsAndFolders = pairs
// 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)
}
}
}

View File

@ -0,0 +1,78 @@
//
// FeedlyOrganiseParsedItemsByFeedOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSParser
import os.log
protocol FeedlyParsedItemsByFeedProviding {
var collection: FeedlyCollection { get }
var stream: FeedlyStream { get }
var allFeeds: Set<Feed> { get }
func parsedItems(for feed: Feed) -> Set<ParsedItem>?
}
/// Single responsibility is to group articles by their feeds.
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlySyncOperation, FeedlyParsedItemsByFeedProviding {
private let account: Account
private let parsedItemsProvider: FeedlyStreamParsedItemsProviding
private let log: OSLog
var allFeeds: Set<Feed> {
let keys = Set(itemsKeyedByFeedId.keys)
return account.flattenedFeeds().filter { keys.contains($0.feedID) }
}
func parsedItems(for feed: Feed) -> Set<ParsedItem>? {
return itemsKeyedByFeedId[feed.feedID]
}
var collection: FeedlyCollection {
return parsedItemsProvider.collection
}
var stream: FeedlyStream {
return parsedItemsProvider.stream
}
private var itemsKeyedByFeedId = [String: Set<ParsedItem>]()
init(account: Account, parsedItemsProvider: FeedlyStreamParsedItemsProviding, log: OSLog) {
self.account = account
self.parsedItemsProvider = parsedItemsProvider
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
let items = parsedItemsProvider.parsedItems
var dict = [String: Set<ParsedItem>](minimumCapacity: items.count)
for item in items {
let key = item.feedURL
let value: Set<ParsedItem> = {
if var items = dict[key] {
items.insert(item)
return items
} else {
return [item]
}
}()
dict[key] = value
guard !isCancelled else { return }
}
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemsProvider.collection.label)
itemsKeyedByFeedId = dict
}
}

View File

@ -0,0 +1,15 @@
//
// FeedlyOrigin.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyOrigin: Decodable {
var title: String
var streamId: String
var htmlUrl: String
}

View File

@ -0,0 +1,50 @@
//
// FeedlyRequestStreamsOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
protocol FeedlyRequestStreamsOperationDelegate: class {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetCollectionStreamOperation)
}
/// Single responsibility is to 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: FeedlySyncOperation {
weak var queueDelegate: FeedlyRequestStreamsOperationDelegate?
let collectionsProvider: FeedlyCollectionProviding
let caller: FeedlyAPICaller
let account: Account
let log: OSLog
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.collectionsProvider = collectionsProvider
self.log = log
}
override func main() {
defer { didFinish() }
guard !isCancelled else { return }
assert(queueDelegate != nil, "This is not particularly useful unless the `queueDelegate` is non-nil.")
// TODO: Prioritise the must read collection/category before others so the most important content for the user loads first.
for collection in collectionsProvider.collections {
let operation = FeedlyGetCollectionStreamOperation(account: account, collection: collection, caller: caller)
queueDelegate?.feedlyRequestStreamsOperation(self, enqueue: operation)
}
os_log(.debug, log: log, "Requested %i collection streams", collectionsProvider.collections.count)
}
}

View File

@ -0,0 +1,20 @@
//
// FeedlyStream.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedlyStream: Decodable {
var id: String
var timestamp: Date?
var continuation: String?
var items: [FeedlyEntry]
var isStreamEnd: Bool {
return continuation == nil
}
}

View File

@ -0,0 +1,68 @@
//
// FeedlySyncOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
protocol FeedlySyncOperationDelegate: class {
func feedlySyncOperation(_ operation: FeedlySyncOperation, 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 FeedlySyncOperation: Operation {
weak var delegate: FeedlySyncOperationDelegate?
func didFinish() {
self.isExecutingOperation = false
self.isFinishedOperation = true
}
func didFinish(_ error: Error) {
assert(delegate != nil)
delegate?.feedlySyncOperation(self, didFailWith: error)
didFinish()
}
override func start() {
isExecutingOperation = true
DispatchQueue.main.async {
self.main()
}
}
override func cancel() {
super.cancel()
}
override var isExecuting: Bool {
return isExecutingOperation
}
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)
}
}
}

View File

@ -0,0 +1,150 @@
//
// FeedlySyncStrategy.swift
// Account
//
// Created by Kiel Gillard on 19/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
final class FeedlySyncStrategy {
let account: Account
let caller: FeedlyAPICaller
let operationQueue: OperationQueue
let log: OSLog
init(account: Account, caller: FeedlyAPICaller, log: OSLog) {
self.account = account
self.caller = caller
self.operationQueue = OperationQueue()
self.log = log
}
func cancel() {
os_log(.debug, log: log, "Cancelling all operations.")
self.operationQueue.cancelAllOperations()
}
private var startSyncCompletionHandler: ((Result<Void, Error>) -> ())?
/// The truth is in the cloud.
func startSync(completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard operationQueue.operationCount == 0 else {
os_log(.debug, log: log, "Reqeusted start sync but ignored because a sync is already in progress.")
return
}
// Since the truth is in the cloud, everything hinges of what Collections the user has.
let getCollections = FeedlyGetCollectionsOperation(caller: caller)
getCollections.delegate = self
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account,
collectionsProvider: getCollections,
caller: caller)
mirrorCollectionsAsFolders.delegate = self
mirrorCollectionsAsFolders.addDependency(getCollections)
// Ensure feeds are created and grouped by their folders.
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account,
collectionsAndFoldersProvider: mirrorCollectionsAsFolders,
log: log)
createFeedsOperation.delegate = self
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
// Get the streams for each Collection. It will call back to enqueue more operations.
let getCollectionStreams = FeedlyRequestStreamsOperation(account: account,
collectionsProvider: getCollections,
caller: caller,
log: log)
getCollectionStreams.delegate = self
getCollectionStreams.queueDelegate = self
getCollectionStreams.addDependency(getCollections)
// Last operation to perform, which should be dependent on any other operation added to the queue.
let syncId = UUID().uuidString
let completionOperation = BlockOperation { [weak self] in
if let self = self {
os_log(.debug, log: self.log, "Sync completed: %@", syncId)
self.startSyncCompletionHandler = nil
}
completionHandler(.success(()))
}
completionOperation.addDependency(getCollections)
completionOperation.addDependency(mirrorCollectionsAsFolders)
completionOperation.addDependency(createFeedsOperation)
completionOperation.addDependency(getCollectionStreams)
finalOperation = completionOperation
startSyncCompletionHandler = completionHandler
let minimumOperations = [getCollections,
mirrorCollectionsAsFolders,
createFeedsOperation,
getCollectionStreams,
completionOperation]
operationQueue.addOperations(minimumOperations, waitUntilFinished: false)
os_log(.debug, log: log, "Sync started: %@", syncId)
}
private var finalOperation: Operation?
}
extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetCollectionStreamOperation) {
collectionStreamOperation.delegate = self
os_log(.debug, log: log, "Requesting stream for collection \"%@\"", collectionStreamOperation.collection.label)
// Parse the contents of this collection's stream.
let parseItemsOperation = FeedlyGetStreamParsedItemsOperation(account: account,
collectionStreamProvider: collectionStreamOperation,
caller: caller,
log: log)
parseItemsOperation.delegate = self
parseItemsOperation.addDependency(collectionStreamOperation)
// Group the stream's content by feed.
let groupItemsByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
parsedItemsProvider: parseItemsOperation,
log: log)
groupItemsByFeed.delegate = self
groupItemsByFeed.addDependency(parseItemsOperation)
// Update the account with the articles for the feeds in the stream.
let updateOperation = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
organisedItemsProvider: groupItemsByFeed,
log: log)
updateOperation.delegate = self
updateOperation.addDependency(groupItemsByFeed)
// Sync completes successfully when the account has been updated with all the parsedd entries from the stream.
if let operation = finalOperation {
operation.addDependency(updateOperation)
}
let operations = [collectionStreamOperation, parseItemsOperation, groupItemsByFeed, updateOperation]
operationQueue.addOperations(operations, waitUntilFinished: false)
}
}
extension FeedlySyncStrategy: FeedlySyncOperationDelegate {
func feedlySyncOperation(_ operation: FeedlySyncOperation, didFailWith error: Error) {
os_log(.debug, log: log, "**** Operation failed! **** %@", error as NSError)
cancel()
startSyncCompletionHandler?(.failure(error))
startSyncCompletionHandler = nil
}
}

View File

@ -0,0 +1,53 @@
//
// FeedlyUpdateAccountFeedsWithItemsOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSParser
import os.log
/// Single responsibility is to combine the articles with their feeds for a specific account.
final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlySyncOperation {
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() {
guard !isCancelled else {
didFinish()
return
}
let group = DispatchGroup()
let allFeeds = organisedItemsProvider.allFeeds
os_log(.debug, log: log, "Begin updating %i feeds in collection \"%@\"", allFeeds.count, organisedItemsProvider.collection.label)
for feed in allFeeds {
guard let items = organisedItemsProvider.parsedItems(for: feed) else {
continue
}
group.enter()
os_log(.debug, log: log, "Updating %i items for feed \"%@\" in collection \"%@\"", items.count, feed.nameForDisplay, organisedItemsProvider.collection.label)
account.update(feed, parsedItems: items) {
group.leave()
}
}
group.notify(qos: .userInitiated, queue: .main) {
os_log(.debug, log: self.log, "Finished updating feeds in collection \"%@\"", self.organisedItemsProvider.collection.label)
self.didFinish()
}
}
}