Add send sync database contents to Feedbin

This commit is contained in:
Maurice Parker 2019-05-15 11:52:56 -05:00
parent f5f8d67411
commit 6be6c6a682
12 changed files with 252 additions and 20 deletions

View File

@ -286,10 +286,18 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
}
public func refreshAll(completionHandler completion: (() -> Void)? = nil) {
delegate.refreshAll(for: self, completion: completion)
public func refreshAll(completion: (() -> Void)? = nil) {
self.delegate.refreshAll(for: self, completion: completion)
}
public func syncArticleStatus(completion: (() -> Void)? = nil) {
delegate.sendArticleStatus(for: self) { [unowned self] in
self.delegate.refreshArticleStatus(for: self) {
completion?()
}
}
}
public func importOPML(_ opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
delegate.importOPML(for: self, opmlFile: opmlFile) { [weak self] result in
switch result {

View File

@ -31,6 +31,8 @@
51D5875C227F630B00900287 /* tags_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58759227F630B00900287 /* tags_initial.json */; };
51D5875E227F643C00900287 /* AccountFolderSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */; };
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; };
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; };
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; };
841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; };
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; };
841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; };
@ -128,6 +130,8 @@
51D58759227F630B00900287 /* tags_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_initial.json; sourceTree = "<group>"; };
51D5875D227F643C00900287 /* AccountFolderSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFolderSyncTest.swift; sourceTree = "<group>"; };
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
@ -251,9 +255,11 @@
51E490352288C37100C791F0 /* FeedbinDate.swift */,
84CAD7151FDF2E22000F0755 /* FeedbinEntry.swift */,
5133230F22810E5700C30F19 /* FeedbinIcon.swift */,
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */,
84245C841FDDD8CB0074AFBB /* FeedbinSubscription.swift */,
51D58754227F53BE00900287 /* FeedbinTag.swift */,
84D09622217418DC00D77525 /* FeedbinTagging.swift */,
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */,
);
path = Feedbin;
sourceTree = "<group>";
@ -506,6 +512,7 @@
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
@ -519,6 +526,7 @@
84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */,
5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */,
846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */,
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */,
5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */,
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,

View File

@ -21,6 +21,9 @@ protocol AccountDelegate {
var refreshProgress: DownloadProgress { get }
func refreshAll(for account: Account, completion: (() -> Void)?)
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void))
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void))
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void)
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void)

View File

@ -42,7 +42,7 @@ final class FeedbinAPICaller: NSObject {
self.transport = transport
}
func validateCredentials(completionHandler completion: @escaping (Result<Bool, Error>) -> Void) {
func validateCredentials(completion: @escaping (Result<Bool, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("authentication.json")
let request = URLRequest(url: callURL, credentials: credentials)
@ -67,7 +67,7 @@ final class FeedbinAPICaller: NSObject {
}
func retrieveTags(completionHandler completion: @escaping (Result<[FeedbinTag]?, Error>) -> Void) {
func retrieveTags(completion: @escaping (Result<[FeedbinTag]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("tags.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.tags]
@ -113,7 +113,7 @@ final class FeedbinAPICaller: NSObject {
}
func retrieveSubscriptions(completionHandler completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) {
func retrieveSubscriptions(completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.subscriptions]
@ -133,7 +133,7 @@ final class FeedbinAPICaller: NSObject {
}
func createSubscription(url: String, completionHandler completion: @escaping (Result<CreateSubscriptionResult, Error>) -> Void) {
func createSubscription(url: String, completion: @escaping (Result<CreateSubscriptionResult, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions.json")
var request = URLRequest(url: callURL, credentials: credentials)
@ -218,7 +218,7 @@ final class FeedbinAPICaller: NSObject {
transport.send(request: request, method: HTTPMethod.delete, completion: completion)
}
func retrieveTaggings(completionHandler completion: @escaping (Result<[FeedbinTagging]?, Error>) -> Void) {
func retrieveTaggings(completion: @escaping (Result<[FeedbinTagging]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("taggings.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.taggings]
@ -279,7 +279,7 @@ final class FeedbinAPICaller: NSObject {
transport.send(request: request, method: HTTPMethod.delete, completion: completion)
}
func retrieveIcons(completionHandler completion: @escaping (Result<[FeedbinIcon]?, Error>) -> Void) {
func retrieveIcons(completion: @escaping (Result<[FeedbinIcon]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("icons.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.icons]
@ -403,7 +403,7 @@ final class FeedbinAPICaller: NSObject {
}
func retrieveUnreadEntries(completionHandler completion: @escaping (Result<[Int]?, Error>) -> Void) {
func retrieveUnreadEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("unread_entries.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.unreadEntries]
@ -423,7 +423,21 @@ final class FeedbinAPICaller: NSObject {
}
func retrieveStarredEntries(completionHandler completion: @escaping (Result<[Int]?, Error>) -> Void) {
func createUnreadEntries(entries: [Int], completion: @escaping (Result<Void, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("unread_entries.json")
let request = URLRequest(url: callURL, credentials: credentials)
let payload = FeedbinUnreadEntry(unreadEntries: entries)
transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion)
}
func deleteUnreadEntries(entries: [Int], completion: @escaping (Result<Void, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("unread_entries.json")
let request = URLRequest(url: callURL, credentials: credentials)
let payload = FeedbinUnreadEntry(unreadEntries: entries)
transport.send(request: request, method: HTTPMethod.delete, payload: payload, completion: completion)
}
func retrieveStarredEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("starred_entries.json")
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.starredEntries]
@ -443,6 +457,20 @@ final class FeedbinAPICaller: NSObject {
}
func createStarredEntries(entries: [Int], completion: @escaping (Result<Void, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("starred_entries.json")
let request = URLRequest(url: callURL, credentials: credentials)
let payload = FeedbinStarredEntry(starredEntries: entries)
transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion)
}
func deleteStarredEntries(entries: [Int], completion: @escaping (Result<Void, Error>) -> Void) {
let callURL = feedbinBaseURL.appendingPathComponent("starred_entries.json")
let request = URLRequest(url: callURL, credentials: credentials)
let payload = FeedbinStarredEntry(starredEntries: entries)
transport.send(request: request, method: HTTPMethod.delete, payload: payload, completion: completion)
}
}
// MARK: Private

View File

@ -104,8 +104,48 @@ final class FeedbinAccountDelegate: AccountDelegate {
}
func refreshArticleStatus(for account: Account, completion: (() -> Void)? = nil) {
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
os_log(.debug, log: log, "Sending article statuses...")
let syncStatuses = database.selectForProcessing()
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()
group.enter()
sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) {
group.leave()
}
group.enter()
sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) {
group.leave()
}
group.enter()
sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) {
group.leave()
}
group.enter()
sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) {
group.leave()
}
group.notify(queue: DispatchQueue.main) { [weak self] in
guard let self = self else { return }
os_log(.debug, log: self.log, "Done sending article statuses.")
completion()
}
}
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
os_log(.debug, log: log, "Refreshing article statuses...")
let group = DispatchGroup()
@ -139,7 +179,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
group.notify(queue: DispatchQueue.main) { [weak self] in
guard let self = self else { return }
os_log(.debug, log: self.log, "Done refreshing article statuses.")
completion?()
completion()
}
}
@ -701,6 +741,36 @@ private extension FeedbinAccountDelegate {
}
func sendArticleStatuses(_ statuses: [SyncStatus],
apiCall: ([Int], @escaping (Result<Void, Error>) -> Void) -> Void,
completion: @escaping (() -> Void)) {
guard !statuses.isEmpty else {
completion()
return
}
let articleIDs = statuses.compactMap { Int($0.articleID) }
let articleIDGroups = articleIDs.chunked(into: 1000)
for articleIDGroup in articleIDGroups {
apiCall(articleIDGroup) { [weak self] result in
switch result {
case .success:
self?.database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } )
completion()
case .failure(let error):
guard let self = self else { return }
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
self.database.resetSelectedForProcessing(articleIDGroup.map { String($0) } )
completion()
}
}
}
}
func importOPMLItems(_ account: Account, items: [RSOPMLItem], parentFolder: Folder?) {
items.forEach { (item) in
@ -1058,14 +1128,14 @@ private extension FeedbinAccountDelegate {
let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs)
DispatchQueue.main.async {
_ = account.markArticles(markUnreadArticles, statusKey: .read, flag: false)
_ = account.update(markUnreadArticles, statusKey: .read, flag: false)
}
// Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs)
let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs)
DispatchQueue.main.async {
_ = account.markArticles(markReadArticles, statusKey: .read, flag: true)
_ = account.update(markReadArticles, statusKey: .read, flag: true)
}
// Save any unread statuses for articles we haven't yet received
@ -1092,14 +1162,14 @@ private extension FeedbinAccountDelegate {
let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs)
let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs)
DispatchQueue.main.async {
_ = account.markArticles(markStarredArticles, statusKey: .starred, flag: true)
_ = account.update(markStarredArticles, statusKey: .starred, flag: true)
}
// Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs)
let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs)
DispatchQueue.main.async {
_ = account.markArticles(markUnstarredArticles, statusKey: .starred, flag: false)
_ = account.update(markUnstarredArticles, statusKey: .starred, flag: false)
}
// Save any starred statuses for articles we haven't yet received

View File

@ -0,0 +1,19 @@
//
// FeedbinStarredEntry.swift
// Account
//
// Created by Maurice Parker on 5/15/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedbinStarredEntry: Codable {
let starredEntries: [Int]
enum CodingKeys: String, CodingKey {
case starredEntries = "starred_entries"
}
}

View File

@ -0,0 +1,19 @@
//
// FeedbinUnreadEntry.swift
// Account
//
// Created by Maurice Parker on 5/15/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
struct FeedbinUnreadEntry: Codable {
let unreadEntries: [Int]
enum CodingKeys: String, CodingKey {
case unreadEntries = "unread_entries"
}
}

View File

@ -37,6 +37,14 @@ final class LocalAccountDelegate: AccountDelegate {
refresher.refreshFeeds(account.flattenedFeeds())
completion?()
}
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
completion()
}
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
completion()
}
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {

View File

@ -27,6 +27,18 @@ public final class SyncDatabase {
syncStatusTable.insertStatuses(statuses)
}
public func selectForProcessing() -> [SyncStatus] {
return syncStatusTable.selectForProcessing()
}
public func resetSelectedForProcessing(_ articleIDs: [String]) {
syncStatusTable.resetSelectedForProcessing(articleIDs)
}
public func deleteSelectedForProcessing(_ articleIDs: [String]) {
syncStatusTable.deleteSelectedForProcessing(articleIDs)
}
}
// MARK: - Private

View File

@ -10,7 +10,7 @@ import Foundation
import Articles
import RSDatabase
public struct SyncStatus {
public struct SyncStatus: Hashable, Equatable {
public let articleID: String
public let key: ArticleStatus.Key
@ -24,7 +24,7 @@ public struct SyncStatus {
self.selected = selected
}
public func databaseDictionary() -> DatabaseDictionary? {
public func databaseDictionary() -> DatabaseDictionary {
return [DatabaseKey.articleID: articleID, DatabaseKey.key: key.rawValue, DatabaseKey.flag: flag, DatabaseKey.selected: selected]
}

View File

@ -7,6 +7,7 @@
//
import Foundation
import Articles
import RSDatabase
final class SyncStatusTable: DatabaseTable {
@ -18,11 +19,67 @@ final class SyncStatusTable: DatabaseTable {
self.queue = queue
}
func selectForProcessing() -> [SyncStatus] {
var statuses: Set<SyncStatus>? = nil
self.queue.updateSync { database in
let updateSQL = "update syncStatus set selected = true"
database.executeUpdate(updateSQL, withArgumentsIn: nil)
let selectSQL = "select * from syncStatus where selected == true"
if let resultSet = database.executeQuery(selectSQL, withArgumentsIn: nil) {
statuses = resultSet.mapToSet(self.statusWithRow)
}
}
return statuses != nil ? Array(statuses!) : [SyncStatus]()
}
func resetSelectedForProcessing(_ articleIDs: [String]) {
self.queue.update { database in
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let updateSQL = "update syncStatus set selected = false where articleID in \(placeholders)"
database.executeUpdate(updateSQL, withArgumentsIn: parameters)
}
}
func deleteSelectedForProcessing(_ articleIDs: [String]) {
self.queue.update { database in
let parameters = articleIDs.map { $0 as AnyObject }
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
let deleteSQL = "delete from syncStatus where articleID in \(placeholders)"
database.executeUpdate(deleteSQL, withArgumentsIn: parameters)
}
}
func insertStatuses(_ statuses: [SyncStatus]) {
self.queue.update { database in
let statusArray = statuses.map { $0.databaseDictionary()! }
let statusArray = statuses.map { $0.databaseDictionary() }
self.insertRows(statusArray, insertType: .orReplace, in: database)
}
}
}
private extension SyncStatusTable {
func statusWithRow(_ row: FMResultSet) -> SyncStatus? {
guard let articleID = row.string(forColumn: DatabaseKey.articleID),
let rawKey = row.string(forColumn: DatabaseKey.key),
let key = ArticleStatus.Key(rawValue: rawKey) else {
return nil
}
let flag = row.bool(forColumn: DatabaseKey.flag)
let selected = row.bool(forColumn: DatabaseKey.selected)
return SyncStatus(articleID: articleID, key: key, flag: flag, selected: selected)
}
}

@ -1 +1 @@
Subproject commit 9c268f00e93f758a79dae04dd8f18d27449221b0
Subproject commit 4682329b70eba64f94500885307f99ed8cbcc938