diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 2c2a0b35b..fe9810bfc 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; + 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */; }; @@ -229,6 +230,7 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; + 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadStory.swift; sourceTree = ""; }; @@ -457,6 +459,7 @@ 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */, + 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, ); path = Models; sourceTree = ""; @@ -1151,6 +1154,7 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */, + 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift new file mode 100644 index 000000000..464fe1fdb --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift @@ -0,0 +1,22 @@ +// +// NewsBlurStoryStatusChange.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurStoryStatusChange { + let hashes: [String] +} + +extension NewsBlurStoryStatusChange { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = hashes.map { URLQueryItem(name: "story_hash", value: $0) } + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 95c50f144..c139c76fa 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -11,11 +11,14 @@ import RSWeb enum NewsBlurError: LocalizedError { case general(message: String) + case unknown var errorDescription: String? { switch self { case .general(let message): return message + case .unknown: + return "An unknown error occurred" } } } @@ -188,4 +191,40 @@ final class NewsBlurAPICaller: NSObject { } } } + + func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_unread", hashes: hashes, completion: completion) + } + + func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hashes_as_read", hashes: hashes, completion: completion) + } + + func star(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_starred", hashes: hashes, completion: completion) + } + + func unstar(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_unstarred", hashes: hashes, completion: completion) + } + + private func sendStoryStatus(endpoint: String, hashes: [String], completion: @escaping (Result) -> Void) { + let callURL = baseURL.appendingPathComponent(endpoint) + + var request = URLRequest(url: callURL, credentials: credentials) + request.httpBody = NewsBlurStoryStatusChange(hashes: hashes).asData + transport.send(request: request, method: HTTPMethod.post) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 8c565209e..84bf1befa 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -116,11 +116,74 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { - completion(.success(())) + os_log(.debug, log: log, "Sending story statuses...") + + database.selectForProcessing { result in + + func processStatuses(_ syncStatuses: [SyncStatus]) { + let createUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == false } + let deleteUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == true } + let createStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == true } + let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false } + + let group = DispatchGroup() + var errorOccurred = false + + group.enter() + self.sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: self.caller.markAsUnread) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(deleteStarredStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: self.caller.star) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: self.caller.unstar) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done sending article statuses.") + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } + + switch result { + case .success(let syncStatuses): + processStatuses(syncStatuses) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + } } func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { - completion(.success(())) + os_log(.debug, log: log, "Refreshing article statuses...") + + // TODO: Fill this in } func refreshStories(for account: Account, completion: @escaping (Result) -> Void) { @@ -192,7 +255,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { - fatalError("markArticles(for:articles:statusKey:flag:) has not been implemented") + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) + } + database.insertStatuses(syncStatuses) + + database.selectPendingCount { result in + if let count = try? result.get(), count > 100 { + self.sendArticleStatus(for: account) { _ in } + } + } + + return try? account.update(articles, statusKey: statusKey, flag: flag) } func accountDidInitialize(_ account: Account) { @@ -485,4 +559,43 @@ extension NewsBlurAccountDelegate { return Set(parsedItems) } + + private func sendStoryStatuses(_ statuses: [SyncStatus], + throttle: Bool, + apiCall: ([String], @escaping (Result) -> Void) -> Void, + completion: @escaping (Result) -> Void) { + guard !statuses.isEmpty else { + completion(.success(())) + return + } + + let group = DispatchGroup() + var errorOccurred = false + + let storyHashes = statuses.compactMap { $0.articleID } + let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit + for storyHashGroup in storyHashGroups { + group.enter() + apiCall(storyHashGroup) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + case .failure(let error): + errorOccurred = true + os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription) + self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + } + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } }