Initial implementation FeedlySyncStrategy and basic usability improvements to allow for downloading and reading articles without crashing.
This commit is contained in:
parent
5e48c45d78
commit
1d965142d9
|
@ -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 */,
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 article’s 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 author’s 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 feed’s 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 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?
|
||||
//
|
||||
// /// 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.
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue