Sends and receives unread statuses #1058. Also implements OMPL import #1043.

This commit is contained in:
Kiel Gillard 2019-09-23 17:29:53 +10:00
parent 486cec80d0
commit 465b6e789b
22 changed files with 389 additions and 100 deletions

View File

@ -79,11 +79,12 @@
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 */; };
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1D1554233431A600F4944C /* FeedlyOperation.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 */; };
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.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 */; };
@ -91,6 +92,7 @@
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 */; };
9EBC31B7233987C1002A567B /* FeedlyArticleStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.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 */; };
@ -217,11 +219,12 @@
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>"; };
9E1D1554233431A600F4944C /* FeedlyOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyOperation.swift; sourceTree = "<group>"; };
9E1D15562334355900F4944C /* FeedlyRequestStreamsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRequestStreamsOperation.swift; sourceTree = "<group>"; };
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>"; };
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyRefreshStreamEntriesStatusOperation.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>"; };
@ -229,6 +232,7 @@
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>"; };
9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyArticleStatusCoordinator.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>"; };
@ -441,8 +445,18 @@
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */,
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */,
9E1D1554233431A600F4944C /* FeedlyOperation.swift */,
9EBC31B6233987C1002A567B /* FeedlyArticleStatusCoordinator.swift */,
9EBC31B32338AC2E002A567B /* Models */,
9EBC31B22338AC0F002A567B /* Refresh */,
);
path = Feedly;
sourceTree = SOURCE_ROOT;
};
9EBC31B22338AC0F002A567B /* Refresh */ = {
isa = PBXGroup;
children = (
9E1D154C233370D800F4944C /* FeedlySyncStrategy.swift */,
9E1D1554233431A600F4944C /* FeedlySyncOperation.swift */,
9E1D154E233371DD00F4944C /* FeedlyGetCollectionsOperation.swift */,
9E1D15502334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift */,
9E12B01F2334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift */,
@ -451,6 +465,14 @@
9E1D155823343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift */,
9E1D155A2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift */,
9E1D155C233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift */,
9E713652233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift */,
);
path = Refresh;
sourceTree = "<group>";
};
9EBC31B32338AC2E002A567B /* Models */ = {
isa = PBXGroup;
children = (
9EAEC60B2332FE830085D7C9 /* FeedlyCollection.swift */,
9EAEC60D2332FEC20085D7C9 /* FeedlyFeed.swift */,
9EAEC623233315F60085D7C9 /* FeedlyEntry.swift */,
@ -458,8 +480,8 @@
9EAEC62723331C350085D7C9 /* FeedlyCategory.swift */,
9EAEC62923331EE70085D7C9 /* FeedlyOrigin.swift */,
);
path = Feedly;
sourceTree = SOURCE_ROOT;
path = Models;
sourceTree = "<group>";
};
D511EEB4202422BB00712EC3 /* xcconfig */ = {
isa = PBXGroup;
@ -657,6 +679,7 @@
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* FeedMetadataFile.swift in Sources */,
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
@ -702,8 +725,9 @@
846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */,
515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */,
844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */,
9E1D1555233431A600F4944C /* FeedlySyncOperation.swift in Sources */,
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */,
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */,
9EBC31B7233987C1002A567B /* FeedlyArticleStatusCoordinator.swift in Sources */,
84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -89,7 +89,7 @@ final class FeedlyAPICaller {
}
}
func getStream(for collection: FeedlyCollection, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
func getStream(for collection: FeedlyCollection, unreadOnly: Bool = false, completionHandler: @escaping (Result<FeedlyStream, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
@ -98,7 +98,8 @@ final class FeedlyAPICaller {
var components = baseUrlComponents
components.path = "/v3/streams/contents"
components.queryItems = [
URLQueryItem(name: "streamId", value: collection.id)
URLQueryItem(name: "streamId", value: collection.id),
URLQueryItem(name: "unreadOnly", value: unreadOnly ? "true" : "false")
]
guard let url = components.url else {
@ -130,7 +131,33 @@ final class FeedlyAPICaller {
}
}
func markAsRead(articleIds: [String], completionHandler: @escaping (Result<Void, Error>) -> ()) {
enum MarkAction {
case read
case unread
case saved
case unsaved
var actionValue: String {
switch self {
case .read:
return "markAsRead"
case .unread:
return "keepUnread"
case .saved:
return "markAsSaved"
case .unsaved:
return "markAsUnsaved"
}
}
}
private struct MarkerEntriesBody: Encodable {
let type = "entries"
var action: String
var entryIds: [String]
}
func mark(_ articleIds: Set<String>, as action: MarkAction, completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
@ -144,37 +171,62 @@ final class FeedlyAPICaller {
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
let json: [String: Any] = [
"action": "markAsRead",
"type": "entries",
"entryIds": articleIds
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIds))
let encoder = JSONEncoder()
let data = try encoder.encode(body)
request.httpBody = data
} catch {
return DispatchQueue.main.async {
completionHandler(.failure(error))
}
}
// 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
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (httpResponse, _)):
if httpResponse.statusCode == 200 {
completionHandler(.success(()))
} else {
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):
completionHandler(.failure(error))
}
}
}
func importOpml(_ opmlData: Data, completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard let accessToken = credentials?.secret else {
return DispatchQueue.main.async {
completionHandler(.failure(CredentialsError.incompleteCredentials))
}
}
var components = baseUrlComponents
components.path = "/v3/opml"
guard let url = components.url else {
fatalError("\(components) does not produce a valid URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType)
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
request.httpBody = opmlData
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
switch result {
case .success(let (httpResponse, _)):
if httpResponse.statusCode == 200 {
completionHandler(.success(()))
} else {
// tempror
completionHandler(.failure(URLError(.cannotDecodeContentData)))
}
case .failure(let error):

View File

@ -42,15 +42,12 @@ final class FeedlyAccountDelegate: AccountDelegate {
var refreshProgress = DownloadProgress(numberOfTasks: 0)
private let database: SyncDatabase
private let caller: FeedlyAPICaller
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
private let articleStatusCoodinator: FeedlyArticleStatusCoordinator
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
database = SyncDatabase(databaseFilePath: databaseFilePath)
if let transport = transport {
caller = FeedlyAPICaller(transport: transport, api: api)
@ -73,6 +70,9 @@ final class FeedlyAccountDelegate: AccountDelegate {
caller = FeedlyAPICaller(transport: session, api: api)
}
articleStatusCoodinator = FeedlyArticleStatusCoordinator(dataFolderPath: dataFolder,
caller: caller,
log: log)
}
// MARK: Account API
@ -93,17 +93,59 @@ final class FeedlyAccountDelegate: AccountDelegate {
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
os_log(.debug, log: log, "*** SKIPPING SEND ARTICLE STATUS ***")
completion()
// Ensure remote articles have the same status as they do locally.
articleStatusCoodinator.sendArticleStatus(for: account, completion: completion)
}
/// Attempts to ensure local articles have the same status as they do remotely.
/// So if the user is using another client roughly simultaneously with this app,
/// this app does its part to ensure the articles have a consistent status between both.
///
/// Feedly has no API that allows the app to fetch the identifiers of unread articles only.
/// The only way to identify unread articles is to pull all of the article data,
/// which is effectively equivalent of a full refresh.
///
/// - Parameter account: The account whose articles have a remote status.
/// - Parameter completion: Call on the main queue.
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
os_log(.debug, log: log, "*** SKIPPING REFRESH ARTICLE STATUS ***")
completion()
refreshAll(for: account) { _ in
completion()
}
}
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
fatalError()
let data: Data
do {
data = try Data(contentsOf: opmlFile)
} catch {
completion(.failure(error))
return
}
os_log(.debug, log: log, "Begin importing OPML...")
isOPMLImportInProgress = true
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.importOpml(data) { result in
switch result {
case .success:
os_log(.debug, log: self.log, "Import OPML done.")
self.refreshProgress.completeTask()
self.isOPMLImportInProgress = false
DispatchQueue.main.async {
completion(.success(()))
}
case .failure(let error):
os_log(.debug, log: self.log, "Import OPML failed.")
self.refreshProgress.completeTask()
self.isOPMLImportInProgress = false
DispatchQueue.main.async {
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
}
}
}
}
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
@ -148,31 +190,21 @@ final class FeedlyAccountDelegate: AccountDelegate {
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
let log = self.log
let acceptedStatuses = articleStatusCoodinator.articles(articles,
for: account,
didChangeStatus: statusKey,
flag: flag)
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
return acceptedStatuses
}
func accountDidInitialize(_ account: Account) {
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
syncStrategy = FeedlySyncStrategy(account: account, caller: caller, log: log)
syncStrategy = FeedlySyncStrategy(account: account,
caller: caller,
articleStatusCoordinator: articleStatusCoodinator,
log: log)
//TODO: Figure out how other accounts get refreshed automatically.
refreshAll(for: account) { result in

View File

@ -0,0 +1,125 @@
//
// FeedlyArticleStatusCoordinator.swift
// Account
//
// Created by Kiel Gillard on 24/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import SyncDatabase
import Articles
import os.log
final class FeedlyArticleStatusCoordinator {
private let database: SyncDatabase
private let log: OSLog
private let caller: FeedlyAPICaller
init(dataFolderPath: String, caller: FeedlyAPICaller, log: OSLog) {
let databaseFilePath = (dataFolderPath as NSString).appendingPathComponent("Sync.sqlite3")
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
self.log = log
self.caller = caller
}
/// Stores a status for a particular article locally.
func articles(_ articles: Set<Article>, for account: Account, didChangeStatus statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
}
database.insertStatuses(syncStatuses)
os_log(.debug, log: log, "Marking %@ as %@.", articles.map { $0.title }, syncStatuses)
if database.selectPendingCount() > 100 {
sendArticleStatus(for: account)
}
return account.update(articles, statusKey: statusKey, flag: flag)
}
/// Ensures local articles have the same status as they do remotely.
func refreshArticleStatus(for account: Account, stream: FeedlyStream, collection: FeedlyCollection, completion: @escaping (() -> Void)) {
guard let folder = account.existingFolder(with: collection.label) else {
completion()
return
}
let unreadArticleIds = Set(
stream.items
.filter { $0.unread }
.map { $0.id }
)
let localArticles = folder.fetchArticles()
let localArticleIds = localArticles.articleIDs()
let readArticleIds = localArticleIds.subtracting(unreadArticleIds)
account.update(localArticles.filter { readArticleIds.contains($0.articleID) }, statusKey: .read, flag: true)
// account.ensureStatuses(readArticleIds, true, .read, true)
account.update(localArticles.filter { unreadArticleIds.contains($0.articleID) }, statusKey: .read, flag: false)
// account.ensureStatuses(unreadArticleIds, false, .read, false)
os_log(.debug, log: log, "Ensured %i UNREAD and %i read article(s) in \"%@\".", unreadArticleIds.count, readArticleIds.count, collection.label)
completion()
// TODO: starred
// group.enter()
// caller.retrieveStarredEntries() { result in
// switch result {
// case .success(let articleIDs):
// self.syncArticleStarredState(account: account, articleIDs: articleIDs)
// group.leave()
// case .failure(let error):
// os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription)
// group.leave()
// }
//
// }
}
/// Ensures remote articles have the same status as they do locally.
func sendArticleStatus(for account: Account, completion: (() -> Void)? = nil) {
os_log(.debug, log: log, "Sending article statuses...")
let pending = database.selectForProcessing()
let statuses: [(status: ArticleStatus.Key, flag: Bool, action: FeedlyAPICaller.MarkAction)] = [
(.read, false, .unread),
(.read, true, .read),
(.starred, true, .saved),
(.starred, false, .unsaved),
]
let group = DispatchGroup()
for pairing in statuses {
let articleIds = pending.filter { $0.key == pairing.status && $0.flag == pairing.flag }
guard !articleIds.isEmpty else {
continue
}
let ids = Set(articleIds.map { $0.articleID })
let database = self.database
group.enter()
caller.mark(ids, as: pairing.action) { result in
switch result {
case .success:
database.deleteSelectedForProcessing(Array(ids))
case .failure:
database.resetSelectedForProcessing(Array(ids))
}
group.leave()
}
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done sending article statuses.")
completion?()
}
}
}

View File

@ -1,5 +1,5 @@
//
// FeedlySyncOperation.swift
// FeedlyOperation.swift
// Account
//
// Created by Kiel Gillard on 20/9/19.
@ -8,15 +8,15 @@
import Foundation
protocol FeedlySyncOperationDelegate: class {
func feedlySyncOperation(_ operation: FeedlySyncOperation, didFailWith error: Error)
protocol FeedlyOperationDelegate: class {
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
}
/// Abstract class common to all the tasks required to ingest content from Feedly into NetNewsWire.
/// Each task should try to have a single responsibility so they can be easily composed with others.
class FeedlySyncOperation: Operation {
class FeedlyOperation: Operation {
weak var delegate: FeedlySyncOperationDelegate?
weak var delegate: FeedlyOperationDelegate?
func didFinish() {
self.isExecutingOperation = false
@ -25,7 +25,7 @@ class FeedlySyncOperation: Operation {
func didFinish(_ error: Error) {
assert(delegate != nil)
delegate?.feedlySyncOperation(self, didFailWith: error)
delegate?.feedlyOperation(self, didFailWith: error)
didFinish()
}

View File

@ -59,8 +59,8 @@ struct FeedlyEntry: Decodable {
// /// 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
/// 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]?

View File

@ -10,7 +10,7 @@ import Foundation
import os.log
/// Single responsibility is to accurately reflect Collections and their Feeds as Folders and their Feeds.
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlySyncOperation {
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
let account: Account
let collectionsAndFoldersProvider: FeedlyCollectionsAndFoldersProviding
@ -52,13 +52,9 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlySyncOperation
let url = collectionFeed.id
let metadata = FeedMetadata(feedID: url)
// TODO: More metadata
// Kiel, I'm commenting this out as we shouldn't be storing the name
// in the feed metadata. It should be stored in the OPML file.
// You can just set the name directly on the feed itself.
// metadata.name = collectionFeed.title
let feed = Feed(account: account, url: url, metadata: metadata)
feed.name = collectionFeed.title
// So the same feed isn't created more than once.
localFeeds.insert(feed)

View File

@ -14,7 +14,7 @@ protocol FeedlyCollectionStreamProviding: class {
}
/// Single responsibility is to get the stream content of a Collection from Feedly.
final class FeedlyGetCollectionStreamOperation: FeedlySyncOperation, FeedlyCollectionStreamProviding {
final class FeedlyGetCollectionStreamOperation: FeedlyOperation, FeedlyCollectionStreamProviding {
private(set) var collection: FeedlyCollection
@ -30,11 +30,13 @@ final class FeedlyGetCollectionStreamOperation: FeedlySyncOperation, FeedlyColle
let account: Account
let caller: FeedlyAPICaller
let unreadOnly: Bool
init(account: Account, collection: FeedlyCollection, caller: FeedlyAPICaller) {
init(account: Account, collection: FeedlyCollection, caller: FeedlyAPICaller, unreadOnly: Bool = false) {
self.account = account
self.collection = collection
self.caller = caller
self.unreadOnly = unreadOnly
}
override func main() {
@ -44,7 +46,7 @@ final class FeedlyGetCollectionStreamOperation: FeedlySyncOperation, FeedlyColle
}
//TODO: Use account metadata to get articles newer than some date.
caller.getStream(for: collection) { result in
caller.getStream(for: collection, unreadOnly: unreadOnly) { result in
switch result {
case .success(let stream):
self.storedStream = stream

View File

@ -7,20 +7,23 @@
//
import Foundation
import os.log
protocol FeedlyCollectionProviding: class {
var collections: [FeedlyCollection] { get }
}
/// Single responsibility is to get Collections from Feedly.
final class FeedlyGetCollectionsOperation: FeedlySyncOperation, FeedlyCollectionProviding {
final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding {
let caller: FeedlyAPICaller
let log: OSLog
private(set) var collections = [FeedlyCollection]()
init(caller: FeedlyAPICaller) {
init(caller: FeedlyAPICaller, log: OSLog) {
self.caller = caller
self.log = log
}
override func main() {
@ -29,13 +32,17 @@ final class FeedlyGetCollectionsOperation: FeedlySyncOperation, FeedlyCollection
return
}
os_log(.debug, log: log, "Requesting collections.")
caller.getCollections { result in
switch result {
case .success(let collections):
os_log(.debug, log: self.log, "Received collections: %@.", collections.map { $0.id })
self.collections = collections
self.didFinish()
case .failure(let error):
os_log(.debug, log: self.log, "Unable to request collections %@.", error as NSError)
self.didFinish(error)
}
}

View File

@ -17,7 +17,7 @@ protocol FeedlyStreamParsedItemsProviding: class {
}
/// Single responsibility is to model articles as ParsedItems for entries in a Collection's stream from Feedly.
final class FeedlyGetStreamParsedItemsOperation: FeedlySyncOperation, FeedlyStreamParsedItemsProviding {
final class FeedlyGetStreamParsedItemsOperation: FeedlyOperation, FeedlyStreamParsedItemsProviding {
private let account: Account
private let caller: FeedlyAPICaller
private let collectionStreamProvider: FeedlyCollectionStreamProviding

View File

@ -7,24 +7,27 @@
//
import Foundation
import os.log
protocol FeedlyCollectionsAndFoldersProviding: class {
var collectionsAndFolders: [(FeedlyCollection, Folder)] { get }
}
/// Single responsibility is accurately reflect Collections from Feedly as Folders.
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlySyncOperation, FeedlyCollectionsAndFoldersProviding {
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyCollectionsAndFoldersProviding {
let caller: FeedlyAPICaller
let account: Account
let collectionsProvider: FeedlyCollectionProviding
let log: OSLog
private(set) var collectionsAndFolders = [(FeedlyCollection, Folder)]()
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller) {
init(account: Account, collectionsProvider: FeedlyCollectionProviding, caller: FeedlyAPICaller, log: OSLog) {
self.collectionsProvider = collectionsProvider
self.account = account
self.caller = caller
self.log = log
}
override func main() {
@ -36,21 +39,16 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlySyncOperation, Feed
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.")
guard let folder = account.ensureFolder(with: collection.label) else {
assertionFailure("Why wasn't a folder created?")
return nil
}
return (collection, newFolder)
folder.externalID = collection.id
return (collection, folder)
}
collectionsAndFolders = pairs
os_log(.debug, log: log, "Ensured %i folders for %i collections.", pairs.count, collections.count)
// Remove folders without a corresponding collection
let collectionFolders = Set(pairs.map { $0.1 })
@ -58,5 +56,7 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlySyncOperation, Feed
for unmatched in foldersWithoutCollections {
account.removeFolder(unmatched)
}
os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay })
}
}

View File

@ -18,7 +18,7 @@ protocol FeedlyParsedItemsByFeedProviding {
}
/// Single responsibility is to group articles by their feeds.
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlySyncOperation, FeedlyParsedItemsByFeedProviding {
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding {
private let account: Account
private let parsedItemsProvider: FeedlyStreamParsedItemsProviding
private let log: OSLog

View File

@ -0,0 +1,38 @@
//
// FeedlyRefreshStreamEntriesStatusOperation.swift
// Account
//
// Created by Kiel Gillard on 25/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
/// Single responsibility is to update the read status of articles stored locally with the unread status of the entries in a Collection's stream from Feedly.
final class FeedlyRefreshStreamEntriesStatusOperation: FeedlyOperation {
private let account: Account
private let collectionStreamProvider: FeedlyCollectionStreamProviding
private let log: OSLog
let articleStatusCoordinator: FeedlyArticleStatusCoordinator
init(account: Account, collectionStreamProvider: FeedlyCollectionStreamProviding, articleStatusCoordinator: FeedlyArticleStatusCoordinator, log: OSLog) {
self.account = account
self.articleStatusCoordinator = articleStatusCoordinator
self.collectionStreamProvider = collectionStreamProvider
self.log = log
}
override func main() {
guard !isCancelled else {
didFinish()
return
}
let collection = collectionStreamProvider.collection
let stream = collectionStreamProvider.stream
articleStatusCoordinator.refreshArticleStatus(for: account, stream: stream, collection: collection) {
self.didFinish()
}
}
}

View File

@ -15,7 +15,7 @@ protocol FeedlyRequestStreamsOperationDelegate: class {
/// 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 {
final class FeedlyRequestStreamsOperation: FeedlyOperation {
weak var queueDelegate: FeedlyRequestStreamsOperationDelegate?

View File

@ -14,13 +14,15 @@ final class FeedlySyncStrategy {
let account: Account
let caller: FeedlyAPICaller
let operationQueue: OperationQueue
let articleStatusCoordinator: FeedlyArticleStatusCoordinator
let log: OSLog
init(account: Account, caller: FeedlyAPICaller, log: OSLog) {
init(account: Account, caller: FeedlyAPICaller, articleStatusCoordinator: FeedlyArticleStatusCoordinator, log: OSLog) {
self.account = account
self.caller = caller
self.operationQueue = OperationQueue()
self.log = log
self.articleStatusCoordinator = articleStatusCoordinator
}
func cancel() {
@ -34,17 +36,19 @@ final class FeedlySyncStrategy {
func startSync(completionHandler: @escaping (Result<Void, Error>) -> ()) {
guard operationQueue.operationCount == 0 else {
os_log(.debug, log: log, "Reqeusted start sync but ignored because a sync is already in progress.")
completionHandler(.success(()))
return
}
// Since the truth is in the cloud, everything hinges of what Collections the user has.
let getCollections = FeedlyGetCollectionsOperation(caller: caller)
let getCollections = FeedlyGetCollectionsOperation(caller: caller, log: log)
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)
caller: caller,
log: log)
mirrorCollectionsAsFolders.delegate = self
mirrorCollectionsAsFolders.addDependency(getCollections)
@ -94,7 +98,7 @@ final class FeedlySyncStrategy {
os_log(.debug, log: log, "Sync started: %@", syncId)
}
private var finalOperation: Operation?
private weak var finalOperation: Operation?
}
extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
@ -127,21 +131,30 @@ extension FeedlySyncStrategy: FeedlyRequestStreamsOperationDelegate {
updateOperation.delegate = self
updateOperation.addDependency(groupItemsByFeed)
// Once the articles are in the account, ensure they have the correct status
let ensureUnreadOperation = FeedlyRefreshStreamEntriesStatusOperation(account: account,
collectionStreamProvider: collectionStreamOperation,
articleStatusCoordinator: articleStatusCoordinator,
log: log)
ensureUnreadOperation.delegate = self
ensureUnreadOperation.addDependency(updateOperation)
// Sync completes successfully when the account has been updated with all the parsedd entries from the stream.
if let operation = finalOperation {
operation.addDependency(updateOperation)
operation.addDependency(ensureUnreadOperation)
}
let operations = [collectionStreamOperation, parseItemsOperation, groupItemsByFeed, updateOperation]
let operations = [collectionStreamOperation, parseItemsOperation, groupItemsByFeed, updateOperation, ensureUnreadOperation]
operationQueue.addOperations(operations, waitUntilFinished: false)
}
}
extension FeedlySyncStrategy: FeedlySyncOperationDelegate {
extension FeedlySyncStrategy: FeedlyOperationDelegate {
func feedlySyncOperation(_ operation: FeedlySyncOperation, didFailWith error: Error) {
os_log(.debug, log: log, "**** Operation failed! **** %@", error as NSError)
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
os_log(.debug, log: log, "%@ failed so sync failed with error %@", operation, error.localizedDescription)
cancel()
startSyncCompletionHandler?(.failure(error))

View File

@ -11,7 +11,7 @@ import RSParser
import os.log
/// Single responsibility is to combine the articles with their feeds for a specific account.
final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlySyncOperation {
final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation {
private let account: Account
private let organisedItemsProvider: FeedlyParsedItemsByFeedProviding
private let log: OSLog
@ -40,7 +40,7 @@ final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlySyncOperation {
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) {
account.update(feed, parsedItems: items, defaultRead: true) {
group.leave()
}
}

View File

@ -70,7 +70,7 @@ class AccountsFeedlyWebWindowController: NSWindowController, WKNavigationDelegat
NSApplication.shared.presentError(error)
} catch {
NSApplication.shared.presentError(error)
print(error)
}
decisionHandler(.allow)