mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move modules to Modules folder.
This commit is contained in:
5
Modules/Account/.gitignore
vendored
Normal file
5
Modules/Account/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Account"
|
||||
BuildableName = "Account"
|
||||
BlueprintName = "Account"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AccountTests"
|
||||
BuildableName = "AccountTests"
|
||||
BlueprintName = "AccountTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Account"
|
||||
BuildableName = "Account"
|
||||
BlueprintName = "Account"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
46
Modules/Account/Package.swift
Normal file
46
Modules/Account/Package.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
// swift-tools-version:5.10
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Account",
|
||||
platforms: [.macOS(.v14), .iOS(.v17)],
|
||||
products: [
|
||||
.library(
|
||||
name: "Account",
|
||||
type: .dynamic,
|
||||
targets: ["Account"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../RSWeb"),
|
||||
.package(path: "../Articles"),
|
||||
.package(path: "../ArticlesDatabase"),
|
||||
.package(path: "../Secrets"),
|
||||
.package(path: "../SyncDatabase"),
|
||||
.package(path: "../RSCore"),
|
||||
.package(path: "../RSDatabase"),
|
||||
.package(path: "../Parser"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Account",
|
||||
dependencies: [
|
||||
"RSCore",
|
||||
"RSDatabase",
|
||||
"Parser",
|
||||
"RSWeb",
|
||||
"Articles",
|
||||
"ArticlesDatabase",
|
||||
"Secrets",
|
||||
"SyncDatabase",
|
||||
],
|
||||
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AccountTests",
|
||||
dependencies: ["Account"],
|
||||
resources: [
|
||||
.copy("JSON"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
3
Modules/Account/README.md
Normal file
3
Modules/Account/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Account
|
||||
|
||||
A description of this package.
|
||||
1408
Modules/Account/Sources/Account/Account.swift
Normal file
1408
Modules/Account/Sources/Account/Account.swift
Normal file
File diff suppressed because it is too large
Load Diff
51
Modules/Account/Sources/Account/AccountBehaviors.swift
Normal file
51
Modules/Account/Sources/Account/AccountBehaviors.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// AccountBehaviors.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 9/20/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Account specific behaviors are used to support different sync services. These sync
|
||||
services don't all act the same and we need to reflect their differences in the
|
||||
user interface as much as possible. For example some sync services don't allow
|
||||
feeds to be in the root folder of the account.
|
||||
*/
|
||||
public typealias AccountBehaviors = [AccountBehavior]
|
||||
|
||||
public enum AccountBehavior: Equatable {
|
||||
|
||||
/**
|
||||
Account doesn't support copies of a feed that are in a folder to be made to the root folder.
|
||||
*/
|
||||
case disallowFeedCopyInRootFolder
|
||||
|
||||
/**
|
||||
Account doesn't support feeds in the root folder.
|
||||
*/
|
||||
case disallowFeedInRootFolder
|
||||
|
||||
/**
|
||||
Account doesn't support a feed being in more than one folder.
|
||||
*/
|
||||
case disallowFeedInMultipleFolders
|
||||
|
||||
/**
|
||||
Account doesn't support folders
|
||||
*/
|
||||
case disallowFolderManagement
|
||||
|
||||
/**
|
||||
Account doesn't support OPML imports
|
||||
*/
|
||||
case disallowOPMLImports
|
||||
|
||||
/**
|
||||
Account doesn't allow mark as read after a period of days
|
||||
*/
|
||||
case disallowMarkAsUnreadAfterPeriod(Int)
|
||||
|
||||
}
|
||||
65
Modules/Account/Sources/Account/AccountDelegate.swift
Normal file
65
Modules/Account/Sources/Account/AccountDelegate.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// AccountDelegate.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
protocol AccountDelegate {
|
||||
|
||||
var behaviors: AccountBehaviors { get }
|
||||
|
||||
var isOPMLImportInProgress: Bool { get }
|
||||
|
||||
var server: String? { get }
|
||||
var credentials: Credentials? { get set }
|
||||
var accountMetadata: AccountMetadata? { get set }
|
||||
|
||||
var refreshProgress: DownloadProgress { get }
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void)
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func syncArticleStatus(for account: Account, completion: ((Result<Void, Error>) -> Void)?)
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void)
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> Void)
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
// Called at the end of account’s init method.
|
||||
func accountDidInitialize(_ account: Account)
|
||||
|
||||
func accountWillBeDeleted(_ account: Account)
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void)
|
||||
|
||||
/// Suspend all network activity
|
||||
func suspendNetwork()
|
||||
|
||||
/// Suspend the SQLite databases
|
||||
func suspendDatabase()
|
||||
|
||||
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||
func resume()
|
||||
}
|
||||
96
Modules/Account/Sources/Account/AccountError.swift
Normal file
96
Modules/Account/Sources/Account/AccountError.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// AccountError.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
public enum AccountError: LocalizedError {
|
||||
|
||||
case createErrorNotFound
|
||||
case createErrorAlreadySubscribed
|
||||
case opmlImportInProgress
|
||||
case wrappedError(error: Error, account: Account)
|
||||
|
||||
public var account: Account? {
|
||||
if case .wrappedError(_, let account) = self {
|
||||
return account
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var isCredentialsError: Bool {
|
||||
if case .wrappedError(let error, _) = self {
|
||||
if case TransportError.httpError(let status) = error {
|
||||
return isCredentialsError(status: status)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .createErrorNotFound:
|
||||
return NSLocalizedString("The feed couldn’t be found and can’t be added.", comment: "Not found")
|
||||
case .createErrorAlreadySubscribed:
|
||||
return NSLocalizedString("You are already subscribed to this feed and can’t add it again.", comment: "Already subscribed")
|
||||
case .opmlImportInProgress:
|
||||
return NSLocalizedString("An OPML import for this account is already running.", comment: "Import running")
|
||||
case .wrappedError(let error, let account):
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
if isCredentialsError(status: status) {
|
||||
let localizedText = NSLocalizedString("Your “%@” credentials are invalid or expired.", comment: "Invalid or expired")
|
||||
return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay) as String
|
||||
} else {
|
||||
return unknownError(error, account)
|
||||
}
|
||||
default:
|
||||
return unknownError(error, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .createErrorNotFound:
|
||||
return nil
|
||||
case .createErrorAlreadySubscribed:
|
||||
return nil
|
||||
case .wrappedError(let error, _):
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
if isCredentialsError(status: status) {
|
||||
return NSLocalizedString("Please update your credentials for this account, or ensure that your account with this service is still valid.", comment: "Expired credentials")
|
||||
} else {
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
default:
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
default:
|
||||
return NSLocalizedString("Please try again later.", comment: "Try later")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension AccountError {
|
||||
|
||||
func unknownError(_ error: Error, _ account: Account) -> String {
|
||||
let localizedText = NSLocalizedString("An error occurred while processing the “%@” account: %@", comment: "Unknown error")
|
||||
return NSString.localizedStringWithFormat(localizedText as NSString, account.nameForDisplay, error.localizedDescription) as String
|
||||
}
|
||||
|
||||
func isCredentialsError(status: Int) -> Bool {
|
||||
return status == 401 || status == 403
|
||||
}
|
||||
|
||||
}
|
||||
540
Modules/Account/Sources/Account/AccountManager.swift
Normal file
540
Modules/Account/Sources/Account/AccountManager.swift
Normal file
@@ -0,0 +1,540 @@
|
||||
//
|
||||
// AccountManager.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/18/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import RSDatabase
|
||||
|
||||
// Main thread only.
|
||||
|
||||
public final class AccountManager: UnreadCountProvider {
|
||||
|
||||
public static var shared: AccountManager!
|
||||
public static let netNewsWireNewsURL = "https://netnewswire.blog/feed.xml"
|
||||
private static let jsonNetNewsWireNewsURL = "https://netnewswire.blog/feed.json"
|
||||
|
||||
public let defaultAccount: Account
|
||||
|
||||
private let accountsFolder: String
|
||||
private var accountsDictionary = [String: Account]()
|
||||
|
||||
private let defaultAccountFolderName = "OnMyMac"
|
||||
private let defaultAccountIdentifier = "OnMyMac"
|
||||
|
||||
public var isSuspended = false
|
||||
public var isUnreadCountsInitialized: Bool {
|
||||
for account in activeAccounts {
|
||||
if !account.isUnreadCountsInitialized {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public var unreadCount = 0 {
|
||||
didSet {
|
||||
if unreadCount != oldValue {
|
||||
postUnreadCountDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var accounts: [Account] {
|
||||
return Array(accountsDictionary.values)
|
||||
}
|
||||
|
||||
public var sortedAccounts: [Account] {
|
||||
return sortByName(accounts)
|
||||
}
|
||||
|
||||
public var activeAccounts: [Account] {
|
||||
assert(Thread.isMainThread)
|
||||
return Array(accountsDictionary.values.filter { $0.isActive })
|
||||
}
|
||||
|
||||
public var sortedActiveAccounts: [Account] {
|
||||
return sortByName(activeAccounts)
|
||||
}
|
||||
|
||||
public var lastArticleFetchEndTime: Date? {
|
||||
var lastArticleFetchEndTime: Date? = nil
|
||||
for account in activeAccounts {
|
||||
if let accountLastArticleFetchEndTime = account.metadata.lastArticleFetchEndTime {
|
||||
if lastArticleFetchEndTime == nil || lastArticleFetchEndTime! < accountLastArticleFetchEndTime {
|
||||
lastArticleFetchEndTime = accountLastArticleFetchEndTime
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastArticleFetchEndTime
|
||||
}
|
||||
|
||||
public func existingActiveAccount(forDisplayName displayName: String) -> Account? {
|
||||
return AccountManager.shared.activeAccounts.first(where: { $0.nameForDisplay == displayName })
|
||||
}
|
||||
|
||||
public var refreshInProgress: Bool {
|
||||
for account in activeAccounts {
|
||||
if account.refreshInProgress {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public let combinedRefreshProgress = CombinedRefreshProgress()
|
||||
|
||||
public init(accountsFolder: String) {
|
||||
self.accountsFolder = accountsFolder
|
||||
|
||||
// The local "On My Mac" account must always exist, even if it's empty.
|
||||
let localAccountFolder = (accountsFolder as NSString).appendingPathComponent("OnMyMac")
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: localAccountFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
catch {
|
||||
assertionFailure("Could not create folder for OnMyMac account.")
|
||||
abort()
|
||||
}
|
||||
|
||||
defaultAccount = Account(dataFolder: localAccountFolder, type: .onMyMac, accountID: defaultAccountIdentifier)
|
||||
accountsDictionary[defaultAccount.accountID] = defaultAccount
|
||||
|
||||
readAccountsFromDisk()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
public func createAccount(type: AccountType) -> Account {
|
||||
let accountID = type == .cloudKit ? "iCloud" : UUID().uuidString
|
||||
let accountFolder = (accountsFolder as NSString).appendingPathComponent("\(type.rawValue)_\(accountID)")
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: accountFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
assertionFailure("Could not create folder for \(accountID) account.")
|
||||
abort()
|
||||
}
|
||||
|
||||
let account = Account(dataFolder: accountFolder, type: type, accountID: accountID)
|
||||
accountsDictionary[accountID] = account
|
||||
|
||||
var userInfo = [String: Any]()
|
||||
userInfo[Account.UserInfoKey.account] = account
|
||||
NotificationCenter.default.post(name: .UserDidAddAccount, object: self, userInfo: userInfo)
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
public func deleteAccount(_ account: Account) {
|
||||
guard !account.refreshInProgress else {
|
||||
return
|
||||
}
|
||||
|
||||
account.prepareForDeletion()
|
||||
|
||||
accountsDictionary.removeValue(forKey: account.accountID)
|
||||
account.isDeleted = true
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: account.dataFolder)
|
||||
}
|
||||
catch {
|
||||
assertionFailure("Could not create folder for OnMyMac account.")
|
||||
abort()
|
||||
}
|
||||
|
||||
updateUnreadCount()
|
||||
|
||||
var userInfo = [String: Any]()
|
||||
userInfo[Account.UserInfoKey.account] = account
|
||||
NotificationCenter.default.post(name: .UserDidDeleteAccount, object: self, userInfo: userInfo)
|
||||
}
|
||||
|
||||
public func duplicateServiceAccount(type: AccountType, username: String?) -> Bool {
|
||||
guard type != .onMyMac else {
|
||||
return false
|
||||
}
|
||||
for account in accounts {
|
||||
if account.type == type && username == account.username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func existingAccount(with accountID: String) -> Account? {
|
||||
return accountsDictionary[accountID]
|
||||
}
|
||||
|
||||
public func existingContainer(with containerID: ContainerIdentifier) -> Container? {
|
||||
switch containerID {
|
||||
case .account(let accountID):
|
||||
return existingAccount(with: accountID)
|
||||
case .folder(let accountID, let folderName):
|
||||
return existingAccount(with: accountID)?.existingFolder(with: folderName)
|
||||
default:
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func existingFeed(with feedID: SidebarItemIdentifier) -> SidebarItem? {
|
||||
switch feedID {
|
||||
case .folder(let accountID, let folderName):
|
||||
if let account = existingAccount(with: accountID) {
|
||||
return account.existingFolder(with: folderName)
|
||||
}
|
||||
case .feed(let accountID, let feedID):
|
||||
if let account = existingAccount(with: accountID) {
|
||||
return account.existingFeed(withFeedID: feedID)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func suspendNetworkAll() {
|
||||
isSuspended = true
|
||||
for account in accounts {
|
||||
account.suspendNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
public func suspendDatabaseAll() {
|
||||
for account in accounts {
|
||||
account.suspendDatabase()
|
||||
}
|
||||
}
|
||||
|
||||
public func resumeAll() {
|
||||
isSuspended = false
|
||||
for account in accounts {
|
||||
account.resumeDatabaseAndDelegate()
|
||||
}
|
||||
for account in accounts {
|
||||
account.resume()
|
||||
}
|
||||
}
|
||||
|
||||
public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: (() -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
group.enter()
|
||||
account.receiveRemoteNotification(userInfo: userInfo) {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshAll(errorHandler: ((Error) -> Void)? = nil, completion: (() -> Void)? = nil) {
|
||||
|
||||
guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else {
|
||||
return
|
||||
}
|
||||
|
||||
combinedRefreshProgress.start()
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
group.enter()
|
||||
account.refreshAll() { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
errorHandler?(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
self.combinedRefreshProgress.stop()
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func sendArticleStatusAll(completion: (() -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
group.enter()
|
||||
account.sendArticleStatus() { _ in
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.global(qos: .background)) {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func syncArticleStatusAll(completion: (() -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
group.enter()
|
||||
account.syncArticleStatus() { _ in
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.global(qos: .background)) {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func saveAll() {
|
||||
for account in accounts {
|
||||
account.save()
|
||||
}
|
||||
}
|
||||
|
||||
public func anyAccountHasAtLeastOneFeed() -> Bool {
|
||||
for account in activeAccounts {
|
||||
if account.hasAtLeastOneFeed() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public func anyAccountHasNetNewsWireNewsSubscription() -> Bool {
|
||||
return anyAccountHasFeedWithURL(Self.netNewsWireNewsURL) || anyAccountHasFeedWithURL(Self.jsonNetNewsWireNewsURL)
|
||||
}
|
||||
|
||||
public func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
|
||||
for account in activeAccounts {
|
||||
if let _ = account.existingFeed(withURL: urlString) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Fetching Articles
|
||||
|
||||
// These fetch articles from active accounts and return a merged Set<Article>.
|
||||
|
||||
public func fetchArticles(_ fetchType: FetchType) throws -> Set<Article> {
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
var articles = Set<Article>()
|
||||
for account in activeAccounts {
|
||||
articles.formUnion(try account.fetchArticles(fetchType))
|
||||
}
|
||||
return articles
|
||||
}
|
||||
|
||||
public func fetchArticlesAsync(_ fetchType: FetchType, _ completion: @escaping ArticleSetResultBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
guard activeAccounts.count > 0 else {
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
|
||||
var allFetchedArticles = Set<Article>()
|
||||
var databaseError: DatabaseError?
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
for account in activeAccounts {
|
||||
|
||||
dispatchGroup.enter()
|
||||
|
||||
account.fetchArticlesAsync(fetchType) { (articleSetResult) in
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
switch articleSetResult {
|
||||
case .success(let articles):
|
||||
allFetchedArticles.formUnion(articles)
|
||||
case .failure(let error):
|
||||
databaseError = error
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if let databaseError {
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
else {
|
||||
completion(.success(allFetchedArticles))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetching Article Counts
|
||||
|
||||
public func fetchCountForStarredArticles() throws -> Int {
|
||||
precondition(Thread.isMainThread)
|
||||
var count = 0
|
||||
for account in activeAccounts {
|
||||
count += try account.fetchCountForStarredArticles()
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
/// Empty caches that can reasonably be emptied — when the app moves to the background, for instance.
|
||||
public func emptyCaches() {
|
||||
for account in accounts {
|
||||
account.emptyCaches()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@objc func unreadCountDidInitialize(_ notification: Notification) {
|
||||
guard let _ = notification.object as? Account else {
|
||||
return
|
||||
}
|
||||
if isUnreadCountsInitialized {
|
||||
postUnreadCountDidInitializeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
@objc dynamic func unreadCountDidChange(_ notification: Notification) {
|
||||
guard let _ = notification.object as? Account else {
|
||||
return
|
||||
}
|
||||
updateUnreadCount()
|
||||
}
|
||||
|
||||
@objc func accountStateDidChange(_ notification: Notification) {
|
||||
updateUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension AccountManager {
|
||||
|
||||
func updateUnreadCount() {
|
||||
unreadCount = calculateUnreadCount(activeAccounts)
|
||||
}
|
||||
|
||||
func loadAccount(_ accountSpecifier: AccountSpecifier) -> Account? {
|
||||
return Account(dataFolder: accountSpecifier.folderPath, type: accountSpecifier.type, accountID: accountSpecifier.identifier)
|
||||
}
|
||||
|
||||
func loadAccount(_ filename: String) -> Account? {
|
||||
let folderPath = (accountsFolder as NSString).appendingPathComponent(filename)
|
||||
if let accountSpecifier = AccountSpecifier(folderPath: folderPath) {
|
||||
return loadAccount(accountSpecifier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAccountsFromDisk() {
|
||||
var filenames: [String]?
|
||||
|
||||
do {
|
||||
filenames = try FileManager.default.contentsOfDirectory(atPath: accountsFolder)
|
||||
}
|
||||
catch {
|
||||
print("Error reading Accounts folder: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
filenames = filenames?.sorted()
|
||||
|
||||
if let filenames {
|
||||
for oneFilename in filenames {
|
||||
guard oneFilename != defaultAccountFolderName else {
|
||||
continue
|
||||
}
|
||||
if let oneAccount = loadAccount(oneFilename) {
|
||||
if !duplicateServiceAccount(oneAccount) {
|
||||
accountsDictionary[oneAccount.accountID] = oneAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func duplicateServiceAccount(_ account: Account) -> Bool {
|
||||
return duplicateServiceAccount(type: account.type, username: account.username)
|
||||
}
|
||||
|
||||
func sortByName(_ accounts: [Account]) -> [Account] {
|
||||
// LocalAccount is first.
|
||||
|
||||
return accounts.sorted { (account1, account2) -> Bool in
|
||||
if account1 === defaultAccount {
|
||||
return true
|
||||
}
|
||||
if account2 === defaultAccount {
|
||||
return false
|
||||
}
|
||||
return (account1.nameForDisplay as NSString).localizedStandardCompare(account2.nameForDisplay) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountSpecifier {
|
||||
|
||||
let type: AccountType
|
||||
let identifier: String
|
||||
let folderPath: String
|
||||
let folderName: String
|
||||
let dataFilePath: String
|
||||
|
||||
|
||||
init?(folderPath: String) {
|
||||
if !FileManager.default.isFolder(atPath: folderPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = NSString(string: folderPath).lastPathComponent
|
||||
if name.hasPrefix(".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
let nameComponents = name.components(separatedBy: "_")
|
||||
|
||||
guard nameComponents.count == 2, let rawType = Int(nameComponents[0]), let accountType = AccountType(rawValue: rawType) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.folderPath = folderPath
|
||||
self.folderName = name
|
||||
self.type = accountType
|
||||
self.identifier = nameComponents[1]
|
||||
|
||||
self.dataFilePath = AccountSpecifier.accountFilePathWithFolder(self.folderPath)
|
||||
}
|
||||
|
||||
private static let accountDataFileName = "AccountData.plist"
|
||||
|
||||
private static func accountFilePathWithFolder(_ folderPath: String) -> String {
|
||||
return NSString(string: folderPath).appendingPathComponent(accountDataFileName)
|
||||
}
|
||||
}
|
||||
101
Modules/Account/Sources/Account/AccountMetadata.swift
Normal file
101
Modules/Account/Sources/Account/AccountMetadata.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// AccountMetadata.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 3/3/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
protocol AccountMetadataDelegate: AnyObject {
|
||||
func valueDidChange(_ accountMetadata: AccountMetadata, key: AccountMetadata.CodingKeys)
|
||||
}
|
||||
|
||||
final class AccountMetadata: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case isActive
|
||||
case username
|
||||
case conditionalGetInfo
|
||||
case lastArticleFetchStartTime = "lastArticleFetch"
|
||||
case lastArticleFetchEndTime
|
||||
case endpointURL
|
||||
case externalID
|
||||
case performedApril2020RetentionPolicyChange
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
didSet {
|
||||
if name != oldValue {
|
||||
valueDidChange(.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isActive: Bool = true {
|
||||
didSet {
|
||||
if isActive != oldValue {
|
||||
valueDidChange(.isActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var username: String? {
|
||||
didSet {
|
||||
if username != oldValue {
|
||||
valueDidChange(.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var conditionalGetInfo = [String: HTTPConditionalGetInfo]() {
|
||||
didSet {
|
||||
if conditionalGetInfo != oldValue {
|
||||
valueDidChange(.conditionalGetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastArticleFetchStartTime: Date? {
|
||||
didSet {
|
||||
if lastArticleFetchStartTime != oldValue {
|
||||
valueDidChange(.lastArticleFetchStartTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastArticleFetchEndTime: Date? {
|
||||
didSet {
|
||||
if lastArticleFetchEndTime != oldValue {
|
||||
valueDidChange(.lastArticleFetchEndTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var endpointURL: URL? {
|
||||
didSet {
|
||||
if endpointURL != oldValue {
|
||||
valueDidChange(.endpointURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var performedApril2020RetentionPolicyChange: Bool? // No longer used.
|
||||
|
||||
var externalID: String? {
|
||||
didSet {
|
||||
if externalID != oldValue {
|
||||
valueDidChange(.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: AccountMetadataDelegate?
|
||||
|
||||
func valueDidChange(_ key: CodingKeys) {
|
||||
delegate?.valueDidChange(self, key: key)
|
||||
}
|
||||
}
|
||||
73
Modules/Account/Sources/Account/AccountMetadataFile.swift
Normal file
73
Modules/Account/Sources/Account/AccountMetadataFile.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// AccountMetadataFile.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 9/13/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
|
||||
final class AccountMetadataFile {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "accountMetadataFile")
|
||||
|
||||
private let fileURL: URL
|
||||
private let account: Account
|
||||
|
||||
private var isDirty = false {
|
||||
didSet {
|
||||
queueSaveToDiskIfNeeded()
|
||||
}
|
||||
}
|
||||
private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5)
|
||||
|
||||
init(filename: String, account: Account) {
|
||||
self.fileURL = URL(fileURLWithPath: filename)
|
||||
self.account = account
|
||||
}
|
||||
|
||||
func markAsDirty() {
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
func load() {
|
||||
if let fileData = try? Data(contentsOf: fileURL) {
|
||||
let decoder = PropertyListDecoder()
|
||||
account.metadata = (try? decoder.decode(AccountMetadata.self, from: fileData)) ?? AccountMetadata()
|
||||
}
|
||||
account.metadata.delegate = account
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard !account.isDeleted else { return }
|
||||
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
|
||||
do {
|
||||
let data = try encoder.encode(account.metadata)
|
||||
try data.write(to: fileURL)
|
||||
} catch let error as NSError {
|
||||
os_log(.error, log: log, "Save to disk failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension AccountMetadataFile {
|
||||
|
||||
func queueSaveToDiskIfNeeded() {
|
||||
saveQueue.add(self, #selector(saveToDiskIfNeeded))
|
||||
}
|
||||
|
||||
@objc func saveToDiskIfNeeded() {
|
||||
if isDirty {
|
||||
isDirty = false
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
29
Modules/Account/Sources/Account/AccountSyncError.swift
Normal file
29
Modules/Account/Sources/Account/AccountSyncError.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// AccountSyncError.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Stuart Breckenridge on 24/7/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
public extension Notification.Name {
|
||||
static let AccountsDidFailToSyncWithErrors = Notification.Name("AccountsDidFailToSyncWithErrors")
|
||||
}
|
||||
|
||||
public struct AccountSyncError {
|
||||
|
||||
private static let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
|
||||
public let account: Account
|
||||
public let error: Error
|
||||
|
||||
init(account: Account, error: Error) {
|
||||
self.account = account
|
||||
self.error = error
|
||||
os_log(.error, log: AccountSyncError.log, "%@", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
92
Modules/Account/Sources/Account/ArticleFetcher.swift
Normal file
92
Modules/Account/Sources/Account/ArticleFetcher.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// ArticleFetcher.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 2/4/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
||||
public protocol ArticleFetcher {
|
||||
|
||||
func fetchArticles() throws -> Set<Article>
|
||||
func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock)
|
||||
func fetchUnreadArticles() throws -> Set<Article>
|
||||
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock)
|
||||
}
|
||||
|
||||
extension Feed: ArticleFetcher {
|
||||
|
||||
public func fetchArticles() throws -> Set<Article> {
|
||||
return try account?.fetchArticles(.feed(self)) ?? Set<Article>()
|
||||
}
|
||||
|
||||
public func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
account.fetchArticlesAsync(.feed(self), completion)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticles() throws -> Set<Article> {
|
||||
return try fetchArticles().unreadArticles()
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
account.fetchArticlesAsync(.feed(self)) { articleSetResult in
|
||||
switch articleSetResult {
|
||||
case .success(let articles):
|
||||
completion(.success(articles.unreadArticles()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Folder: ArticleFetcher {
|
||||
|
||||
public func fetchArticles() throws -> Set<Article> {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
return try account.fetchArticles(.folder(self, false))
|
||||
}
|
||||
|
||||
public func fetchArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
account.fetchArticlesAsync(.folder(self, false), completion)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticles() throws -> Set<Article> {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
return try account.fetchArticles(.folder(self, true))
|
||||
}
|
||||
|
||||
public func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||
guard let account = account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
completion(.success(Set<Article>()))
|
||||
return
|
||||
}
|
||||
account.fetchArticlesAsync(.folder(self, true), completion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// CKRecord+Extensions.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/29/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
extension CKRecord {
|
||||
|
||||
var externalID: String {
|
||||
return recordID.externalID
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CKRecord.ID {
|
||||
|
||||
var externalID: String {
|
||||
return recordName
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//
|
||||
// CloudKitAppDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/18/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
import SystemConfiguration
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import RSCore
|
||||
import Parser
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
enum CloudKitAccountDelegateError: LocalizedError {
|
||||
case invalidParameter
|
||||
case unknown
|
||||
|
||||
var errorDescription: String? {
|
||||
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
|
||||
}
|
||||
}
|
||||
|
||||
final class CloudKitAccountDelegate: AccountDelegate {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
private let database: SyncDatabase
|
||||
|
||||
private let container: CKContainer = {
|
||||
let orgID = Bundle.main.object(forInfoDictionaryKey: "OrganizationIdentifier") as! String
|
||||
return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire")
|
||||
}()
|
||||
|
||||
private let accountZone: CloudKitAccountZone
|
||||
private let articlesZone: CloudKitArticlesZone
|
||||
|
||||
private let mainThreadOperationQueue = MainThreadOperationQueue()
|
||||
private let refresher: LocalAccountRefresher
|
||||
|
||||
weak var account: Account?
|
||||
|
||||
let behaviors: AccountBehaviors = []
|
||||
let isOPMLImportInProgress = false
|
||||
|
||||
let server: String? = nil
|
||||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
/// refreshProgress is combined sync progress and feed download progress.
|
||||
let refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
private let syncProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String) {
|
||||
|
||||
self.accountZone = CloudKitAccountZone(container: container)
|
||||
self.articlesZone = CloudKitArticlesZone(container: container)
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
|
||||
self.refresher = LocalAccountRefresher()
|
||||
self.refresher.delegate = self
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: refresher.downloadProgress)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(syncProgressDidChange(_:)), name: .DownloadProgressDidChange, object: syncProgress)
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
let op = CloudKitRemoteNotificationOperation(accountZone: accountZone, articlesZone: articlesZone, userInfo: userInfo)
|
||||
op.completionBlock = { mainThreadOperaion in
|
||||
completion()
|
||||
}
|
||||
mainThreadOperationQueue.add(op)
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
guard refreshProgress.isComplete else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
syncProgress.reset()
|
||||
|
||||
let reachability = SCNetworkReachabilityCreateWithName(nil, "apple.com")
|
||||
var flags = SCNetworkReachabilityFlags()
|
||||
guard SCNetworkReachabilityGetFlags(reachability!, &flags), flags.contains(.reachable) else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
standardRefreshAll(for: account, completion: completion)
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account, completion: ((Result<Void, Error>) -> Void)? = nil) {
|
||||
sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion?(.success(()))
|
||||
case .failure(let error):
|
||||
completion?(.failure(error))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
completion?(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
sendArticleStatus(for: account, showProgress: false, completion: completion)
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
let op = CloudKitReceiveStatusOperation(articlesZone: articlesZone)
|
||||
op.completionBlock = { mainThreadOperaion in
|
||||
if mainThreadOperaion.isCanceled {
|
||||
completion(.failure(CloudKitAccountDelegateError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
mainThreadOperationQueue.add(op)
|
||||
}
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
guard refreshProgress.isComplete else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
var fileData: Data?
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: opmlFile)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let opmlData = fileData else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData)
|
||||
let opmlDocument = OPMLParser.document(with: parserData)
|
||||
|
||||
guard let loadDocument = opmlDocument else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
guard let opmlItems = loadDocument.items, let rootExternalID = account.externalID else {
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedItems = OPMLNormalizer.normalize(opmlItems)
|
||||
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in
|
||||
self.syncProgress.completeTask()
|
||||
self.standardRefreshAll(for: account, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url urlString: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
guard let url = URL(string: urlString) else {
|
||||
completion(.failure(LocalAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let editedName = name == nil || name!.isEmpty ? nil : name
|
||||
|
||||
createRSSFeed(for: account, url: url, editedName: editedName, container: container, validateFeed: validateFeed, completion: completion)
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let editedName = name.isEmpty ? nil : name
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.renameFeed(feed, editedName: editedName) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
feed.editedName = name
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
removeFeedFromCloud(for: account, with: feed, from: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
account.clearFeedMetadata(feed)
|
||||
container.removeFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case CloudKitZoneError.corruptAccount:
|
||||
// We got into a bad state and should remove the feed to clear up the bad data
|
||||
account.clearFeedMetadata(feed)
|
||||
container.removeFeed(feed)
|
||||
default:
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.moveFeed(feed, from: fromContainer, to: toContainer) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
fromContainer.removeFeed(feed)
|
||||
toContainer.addFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.addFeed(feed, to: container) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
container.addFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.createFolder(name: name) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
folder.externalID = externalID
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
}
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.renameFolder(folder, to: name) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
folder.name = name
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
syncProgress.addToNumberOfTasksAndRemaining(2)
|
||||
accountZone.findFeedExternalIDs(for: folder) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let feedExternalIDs):
|
||||
|
||||
let feeds = feedExternalIDs.compactMap { account.existingFeed(withExternalID: $0) }
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
for feed in feeds {
|
||||
group.enter()
|
||||
self.removeFeedFromCloud(for: account, with: feed, from: folder) { result in
|
||||
group.leave()
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "Remove folder, remove feed error: %@.", error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.global(qos: .background)) {
|
||||
DispatchQueue.main.async {
|
||||
guard !errorOccurred else {
|
||||
self.syncProgress.completeTask()
|
||||
completion(.failure(CloudKitAccountDelegateError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
self.accountZone.removeFolder(folder) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.syncProgress.completeTask()
|
||||
self.syncProgress.completeTask()
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let name = folder.name else {
|
||||
completion(.failure(LocalAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let feedsToRestore = folder.topLevelFeeds
|
||||
syncProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count)
|
||||
|
||||
accountZone.createFolder(name: name) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
folder.externalID = externalID
|
||||
account.addFolder(folder)
|
||||
|
||||
let group = DispatchGroup()
|
||||
for feed in feedsToRestore {
|
||||
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
|
||||
group.enter()
|
||||
self.restoreFeed(for: account, feed: feed, container: folder) { result in
|
||||
self.syncProgress.completeTask()
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.update(articles, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
case .success(let articles):
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
self.database.insertStatuses(syncStatuses) { _ in
|
||||
self.database.selectPendingCount { result in
|
||||
if let count = try? result.get(), count > 100 {
|
||||
self.sendArticleStatus(for: account, showProgress: false) { _ in }
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
accountZone.delegate = CloudKitAcountZoneDelegate(account: account, articlesZone: articlesZone)
|
||||
articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone)
|
||||
|
||||
database.resetAllSelectedForProcessing()
|
||||
|
||||
// Check to see if this is a new account and initialize anything we need
|
||||
if account.externalID == nil {
|
||||
accountZone.findOrCreateAccount() { result in
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
account.externalID = externalID
|
||||
self.initialRefreshAll(for: account) { _ in }
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
accountZone.subscribeToZoneChanges()
|
||||
articlesZone.subscribeToZoneChanges()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
accountZone.resetChangeToken()
|
||||
articlesZone.resetChangeToken()
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result<Credentials?, Error>) -> Void) {
|
||||
return completion(.success(nil))
|
||||
}
|
||||
|
||||
// MARK: - Suspend and Resume (for iOS)
|
||||
|
||||
func suspendNetwork() {
|
||||
refresher.suspend()
|
||||
}
|
||||
|
||||
func suspendDatabase() {
|
||||
database.suspend()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
refresher.resume()
|
||||
database.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh Progress
|
||||
|
||||
private extension CloudKitAccountDelegate {
|
||||
|
||||
func updateRefreshProgress() {
|
||||
|
||||
refreshProgress.numberOfTasks = refresher.downloadProgress.numberOfTasks + syncProgress.numberOfTasks
|
||||
refreshProgress.numberRemaining = refresher.downloadProgress.numberRemaining + syncProgress.numberRemaining
|
||||
|
||||
// Complete?
|
||||
if refreshProgress.numberOfTasks > 0 && refreshProgress.numberRemaining < 1 {
|
||||
refresher.downloadProgress.numberOfTasks = 0
|
||||
syncProgress.numberOfTasks = 0
|
||||
}
|
||||
}
|
||||
|
||||
@objc func downloadProgressDidChange(_ note: Notification) {
|
||||
|
||||
updateRefreshProgress()
|
||||
}
|
||||
|
||||
@objc func syncProgressDidChange(_ note: Notification) {
|
||||
|
||||
updateRefreshProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension CloudKitAccountDelegate {
|
||||
|
||||
func initialRefreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
func fail(_ error: Error) {
|
||||
self.processAccountError(account, error)
|
||||
self.syncProgress.reset()
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
syncProgress.addToNumberOfTasksAndRemaining(3)
|
||||
accountZone.fetchChangesInZone() { result in
|
||||
self.syncProgress.completeTask()
|
||||
|
||||
let feeds = account.flattenedFeeds()
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
self.combinedRefresh(account, feeds) {
|
||||
self.syncProgress.reset()
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func standardRefreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
syncProgress.addToNumberOfTasksAndRemaining(3)
|
||||
|
||||
func fail(_ error: Error) {
|
||||
self.processAccountError(account, error)
|
||||
self.syncProgress.reset()
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
accountZone.fetchChangesInZone() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
self.syncProgress.completeTask()
|
||||
let feeds = account.flattenedFeeds()
|
||||
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.syncProgress.completeTask()
|
||||
self.combinedRefresh(account, feeds) {
|
||||
self.sendArticleStatus(for: account, showProgress: true) { _ in
|
||||
self.syncProgress.reset()
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func combinedRefresh(_ account: Account, _ feeds: Set<Feed>, completion: @escaping () -> Void) {
|
||||
|
||||
refresher.refreshFeeds(feeds, completion: completion)
|
||||
}
|
||||
|
||||
func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
func addDeadFeed() {
|
||||
let feed = account.createFeed(with: editedName, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil)
|
||||
container.addFeed(feed)
|
||||
|
||||
self.accountZone.createFeed(url: url.absoluteString,
|
||||
name: editedName,
|
||||
editedName: nil,
|
||||
homePageURL: nil,
|
||||
container: container) { result in
|
||||
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
feed.externalID = externalID
|
||||
completion(.success(feed))
|
||||
case .failure(let error):
|
||||
container.removeFeed(feed)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncProgress.addToNumberOfTasksAndRemaining(5)
|
||||
FeedFinder.find(url: url) { result in
|
||||
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let feedSpecifiers):
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else {
|
||||
self.syncProgress.completeTasks(3)
|
||||
if validateFeed {
|
||||
self.syncProgress.completeTask()
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
} else {
|
||||
addDeadFeed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if account.hasFeed(withURL: bestFeedSpecifier.urlString) {
|
||||
self.syncProgress.completeTasks(4)
|
||||
completion(.failure(AccountError.createErrorAlreadySubscribed))
|
||||
return
|
||||
}
|
||||
|
||||
let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil)
|
||||
feed.editedName = editedName
|
||||
container.addFeed(feed)
|
||||
|
||||
InitialFeedDownloader.download(url) { parsedFeed in
|
||||
self.syncProgress.completeTask()
|
||||
|
||||
if let parsedFeed = parsedFeed {
|
||||
account.update(feed, with: parsedFeed) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
self.accountZone.createFeed(url: bestFeedSpecifier.urlString,
|
||||
name: parsedFeed.title,
|
||||
editedName: editedName,
|
||||
homePageURL: parsedFeed.homePageURL,
|
||||
container: container) { result in
|
||||
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
feed.externalID = externalID
|
||||
self.sendNewArticlesToTheCloud(account, feed)
|
||||
completion(.success(feed))
|
||||
case .failure(let error):
|
||||
container.removeFeed(feed)
|
||||
self.syncProgress.completeTasks(2)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
container.removeFeed(feed)
|
||||
self.syncProgress.completeTasks(3)
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
self.syncProgress.completeTasks(3)
|
||||
container.removeFeed(feed)
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.syncProgress.completeTasks(3)
|
||||
if validateFeed {
|
||||
self.syncProgress.completeTask()
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
} else {
|
||||
addDeadFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendNewArticlesToTheCloud(_ account: Account, _ feed: Feed) {
|
||||
account.fetchArticlesAsync(.feed(feed)) { result in
|
||||
switch result {
|
||||
case .success(let articles):
|
||||
self.storeArticleChanges(new: articles, updated: Set<Article>(), deleted: Set<Article>()) {
|
||||
self.syncProgress.completeTask()
|
||||
self.sendArticleStatus(for: account, showProgress: true) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.articlesZone.fetchChangesInZone() { _ in }
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "CloudKit Feed send articles error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "CloudKit Feed send articles error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processAccountError(_ account: Account, _ error: Error) {
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
account.removeFeeds(account.topLevelFeeds)
|
||||
for folder in account.folders ?? Set<Folder>() {
|
||||
account.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func storeArticleChanges(new: Set<Article>?, updated: Set<Article>?, deleted: Set<Article>?, completion: (() -> Void)?) {
|
||||
// New records with a read status aren't really new, they just didn't have the read article stored
|
||||
let group = DispatchGroup()
|
||||
if let new = new {
|
||||
let filteredNew = new.filter { $0.status.read == false }
|
||||
group.enter()
|
||||
insertSyncStatuses(articles: filteredNew, statusKey: .new, flag: true) {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.enter()
|
||||
insertSyncStatuses(articles: updated, statusKey: .new, flag: false) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
insertSyncStatuses(articles: deleted, statusKey: .deleted, flag: true) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.global(qos: .userInitiated)) {
|
||||
DispatchQueue.main.async {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertSyncStatuses(articles: Set<Article>?, statusKey: SyncStatus.Key, flag: Bool, completion: @escaping () -> Void) {
|
||||
guard let articles = articles, !articles.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
|
||||
}
|
||||
database.insertStatuses(syncStatuses) { _ in
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, showProgress: Bool, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
let op = CloudKitSendStatusOperation(account: account,
|
||||
articlesZone: articlesZone,
|
||||
refreshProgress: refreshProgress,
|
||||
showProgress: showProgress,
|
||||
database: database)
|
||||
op.completionBlock = { mainThreadOperaion in
|
||||
if mainThreadOperaion.isCanceled {
|
||||
completion(.failure(CloudKitAccountDelegateError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
mainThreadOperationQueue.add(op)
|
||||
}
|
||||
|
||||
|
||||
func removeFeedFromCloud(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
syncProgress.addToNumberOfTasksAndRemaining(2)
|
||||
accountZone.removeFeed(feed, from: container) { result in
|
||||
self.syncProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
guard let feedExternalID = feed.externalID else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
self.articlesZone.deleteArticles(feedExternalID) { result in
|
||||
feed.dropConditionalGetInfo()
|
||||
self.syncProgress.completeTask()
|
||||
completion(result)
|
||||
}
|
||||
case .failure(let error):
|
||||
self.syncProgress.completeTask()
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CloudKitAccountDelegate: LocalAccountRefresherDelegate {
|
||||
|
||||
func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges) {
|
||||
self.storeArticleChanges(new: articleChanges.newArticles,
|
||||
updated: articleChanges.updatedArticles,
|
||||
deleted: articleChanges.deletedArticles,
|
||||
completion: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//
|
||||
// CloudKitAccountZone.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Parser
|
||||
import CloudKit
|
||||
|
||||
enum CloudKitAccountZoneError: LocalizedError {
|
||||
case unknown
|
||||
var errorDescription: String? {
|
||||
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
|
||||
}
|
||||
}
|
||||
final class CloudKitAccountZone: CloudKitZone {
|
||||
|
||||
var zoneID: CKRecordZone.ID
|
||||
|
||||
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var container: CKContainer?
|
||||
weak var database: CKDatabase?
|
||||
var delegate: CloudKitZoneDelegate?
|
||||
|
||||
struct CloudKitFeed {
|
||||
static let recordType = "AccountWebFeed"
|
||||
struct Fields {
|
||||
static let url = "url"
|
||||
static let name = "name"
|
||||
static let editedName = "editedName"
|
||||
static let homePageURL = "homePageURL"
|
||||
static let containerExternalIDs = "containerExternalIDs"
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudKitContainer {
|
||||
static let recordType = "AccountContainer"
|
||||
struct Fields {
|
||||
static let isAccount = "isAccount"
|
||||
static let name = "name"
|
||||
}
|
||||
}
|
||||
|
||||
init(container: CKContainer) {
|
||||
self.container = container
|
||||
self.database = container.privateCloudDatabase
|
||||
self.zoneID = CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName)
|
||||
migrateChangeToken()
|
||||
}
|
||||
|
||||
func importOPML(rootExternalID: String, items: [OPMLItem], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
var records = [CKRecord]()
|
||||
var feedRecords = [String: CKRecord]()
|
||||
|
||||
func processFeed(feedSpecifier: OPMLFeedSpecifier, containerExternalID: String) {
|
||||
if let feedRecord = feedRecords[feedSpecifier.feedURL], var containerExternalIDs = feedRecord[CloudKitFeed.Fields.containerExternalIDs] as? [String] {
|
||||
containerExternalIDs.append(containerExternalID)
|
||||
feedRecord[CloudKitFeed.Fields.containerExternalIDs] = containerExternalIDs
|
||||
} else {
|
||||
let feedRecord = newFeedCKRecord(feedSpecifier: feedSpecifier, containerExternalID: containerExternalID)
|
||||
records.append(feedRecord)
|
||||
feedRecords[feedSpecifier.feedURL] = feedRecord
|
||||
}
|
||||
}
|
||||
|
||||
for item in items {
|
||||
if let feedSpecifier = item.feedSpecifier {
|
||||
processFeed(feedSpecifier: feedSpecifier, containerExternalID: rootExternalID)
|
||||
} else {
|
||||
if let title = item.titleFromAttributes {
|
||||
let containerRecord = newContainerCKRecord(name: title)
|
||||
records.append(containerRecord)
|
||||
if let items = item.items {
|
||||
for itemChild in items {
|
||||
if let feedSpecifier = itemChild.feedSpecifier {
|
||||
processFeed(feedSpecifier: feedSpecifier, containerExternalID: containerRecord.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save(records, completion: completion)
|
||||
}
|
||||
|
||||
/// Persist a web feed record to iCloud and return the external key
|
||||
func createFeed(url: String, name: String?, editedName: String?, homePageURL: String?, container: Container, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let recordID = CKRecord.ID(recordName: url.md5String, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: recordID)
|
||||
record[CloudKitFeed.Fields.url] = url
|
||||
record[CloudKitFeed.Fields.name] = name
|
||||
if let editedName = editedName {
|
||||
record[CloudKitFeed.Fields.editedName] = editedName
|
||||
}
|
||||
if let homePageURL = homePageURL {
|
||||
record[CloudKitFeed.Fields.homePageURL] = homePageURL
|
||||
}
|
||||
|
||||
guard let containerExternalID = container.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
record[CloudKitFeed.Fields.containerExternalIDs] = [containerExternalID]
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(record.externalID))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the given web feed
|
||||
func renameFeed(_ feed: Feed, editedName: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = feed.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: recordID)
|
||||
record[CloudKitFeed.Fields.editedName] = editedName
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a web feed from a container and optionally deletes it, calling the completion with true if deleted
|
||||
func removeFeed(_ feed: Feed, from: Container, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
guard let fromContainerExternalID = from.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: feed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
|
||||
if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.remove(fromContainerExternalID)
|
||||
|
||||
if containerExternalIDSet.isEmpty {
|
||||
self.delete(externalID: feed.externalID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(true))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(false))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
if let ckError = ((error as? CloudKitError)?.error as? CKError), ckError.code == .unknownItem {
|
||||
completion(.success(true))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let fromContainerExternalID = from.externalID, let toContainerExternalID = to.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: feed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.remove(fromContainerExternalID)
|
||||
containerExternalIDSet.insert(toContainerExternalID)
|
||||
record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record, completion: completion)
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFeed(_ feed: Feed, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let toContainerExternalID = to.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: feed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.insert(toContainerExternalID)
|
||||
record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record, completion: completion)
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findFeedExternalIDs(for folder: Folder, completion: @escaping (Result<[String], Error>) -> Void) {
|
||||
guard let folderExternalID = folder.externalID else {
|
||||
completion(.failure(CloudKitAccountZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
let predicate = NSPredicate(format: "containerExternalIDs CONTAINS %@", folderExternalID)
|
||||
let ckQuery = CKQuery(recordType: CloudKitFeed.recordType, predicate: predicate)
|
||||
|
||||
query(ckQuery) { result in
|
||||
switch result {
|
||||
case .success(let records):
|
||||
let feedExternalIds = records.map { $0.externalID }
|
||||
completion(.success(feedExternalIds))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findOrCreateAccount(completion: @escaping (Result<String, Error>) -> Void) {
|
||||
|
||||
guard let database else {
|
||||
completion(.failure(CloudKitAccountZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
let predicate = NSPredicate(format: "isAccount = \"1\"")
|
||||
let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate)
|
||||
|
||||
database.fetch(withQuery: ckQuery, inZoneWith: zoneID) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
|
||||
case .success((let matchResults, _)):
|
||||
|
||||
for result in matchResults {
|
||||
let (_, recordResult) = result
|
||||
switch recordResult {
|
||||
|
||||
case .success(let record):
|
||||
completion(.success(record.externalID))
|
||||
return
|
||||
|
||||
case .failure(_):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
self.createContainer(name: "Account", isAccount: true, completion: completion)
|
||||
|
||||
case .failure(let error):
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.findOrCreateAccount(completion: completion)
|
||||
}
|
||||
|
||||
case .zoneNotFound, .userDeletedZone:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.findOrCreateAccount(completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
self.createContainer(name: "Account", isAccount: true, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createFolder(name: String, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
createContainer(name: name, isAccount: false, completion: completion)
|
||||
}
|
||||
|
||||
func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = folder.externalID else {
|
||||
completion(.failure(CloudKitZoneError.corruptAccount))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID)
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delete(externalID: folder.externalID, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitAccountZone {
|
||||
|
||||
func newFeedCKRecord(feedSpecifier: OPMLFeedSpecifier, containerExternalID: String) -> CKRecord {
|
||||
let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: generateRecordID())
|
||||
record[CloudKitFeed.Fields.url] = feedSpecifier.feedURL
|
||||
if let editedName = feedSpecifier.title {
|
||||
record[CloudKitFeed.Fields.editedName] = editedName
|
||||
}
|
||||
if let homePageURL = feedSpecifier.homePageURL {
|
||||
record[CloudKitFeed.Fields.homePageURL] = homePageURL
|
||||
}
|
||||
record[CloudKitFeed.Fields.containerExternalIDs] = [containerExternalID]
|
||||
return record
|
||||
}
|
||||
|
||||
func newContainerCKRecord(name: String) -> CKRecord {
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID())
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
record[CloudKitContainer.Fields.isAccount] = "0"
|
||||
return record
|
||||
}
|
||||
|
||||
func createContainer(name: String, isAccount: Bool, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID())
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0"
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(record.externalID))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// CloudKitAccountZoneDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/29/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import RSCore
|
||||
import Articles
|
||||
|
||||
class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
private typealias UnclaimedFeed = (url: URL, name: String?, editedName: String?, homePageURL: String?, feedExternalID: String)
|
||||
private var newUnclaimedFeeds = [String: [UnclaimedFeed]]()
|
||||
private var existingUnclaimedFeeds = [String: [Feed]]()
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var account: Account?
|
||||
weak var articlesZone: CloudKitArticlesZone?
|
||||
|
||||
init(account: Account, articlesZone: CloudKitArticlesZone) {
|
||||
self.account = account
|
||||
self.articlesZone = articlesZone
|
||||
}
|
||||
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
for deletedRecordKey in deleted {
|
||||
switch deletedRecordKey.recordType {
|
||||
case CloudKitAccountZone.CloudKitFeed.recordType:
|
||||
removeFeed(deletedRecordKey.recordID.externalID)
|
||||
case CloudKitAccountZone.CloudKitContainer.recordType:
|
||||
removeContainer(deletedRecordKey.recordID.externalID)
|
||||
default:
|
||||
assertionFailure("Unknown record type: \(deletedRecordKey.recordType)")
|
||||
}
|
||||
}
|
||||
|
||||
for changedRecord in changed {
|
||||
switch changedRecord.recordType {
|
||||
case CloudKitAccountZone.CloudKitFeed.recordType:
|
||||
addOrUpdateFeed(changedRecord)
|
||||
case CloudKitAccountZone.CloudKitContainer.recordType:
|
||||
addOrUpdateContainer(changedRecord)
|
||||
default:
|
||||
assertionFailure("Unknown record type: \(changedRecord.recordType)")
|
||||
}
|
||||
}
|
||||
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addOrUpdateFeed(_ record: CKRecord) {
|
||||
guard let account = account,
|
||||
let urlString = record[CloudKitAccountZone.CloudKitFeed.Fields.url] as? String,
|
||||
let containerExternalIDs = record[CloudKitAccountZone.CloudKitFeed.Fields.containerExternalIDs] as? [String],
|
||||
let url = URL(string: urlString) else {
|
||||
return
|
||||
}
|
||||
|
||||
let name = record[CloudKitAccountZone.CloudKitFeed.Fields.name] as? String
|
||||
let editedName = record[CloudKitAccountZone.CloudKitFeed.Fields.editedName] as? String
|
||||
let homePageURL = record[CloudKitAccountZone.CloudKitFeed.Fields.homePageURL] as? String
|
||||
|
||||
if let feed = account.existingFeed(withExternalID: record.externalID) {
|
||||
updateFeed(feed, name: name, editedName: editedName, homePageURL: homePageURL, containerExternalIDs: containerExternalIDs)
|
||||
} else {
|
||||
for containerExternalID in containerExternalIDs {
|
||||
if let container = account.existingContainer(withExternalID: containerExternalID) {
|
||||
createFeedIfNecessary(url: url, name: name, editedName: editedName, homePageURL: homePageURL, feedExternalID: record.externalID, container: container)
|
||||
} else {
|
||||
addNewUnclaimedFeed(url: url, name: name, editedName: editedName, homePageURL: homePageURL, feedExternalID: record.externalID, containerExternalID: containerExternalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(_ externalID: String) {
|
||||
if let feed = account?.existingFeed(withExternalID: externalID), let containers = account?.existingContainers(withFeed: feed) {
|
||||
for container in containers {
|
||||
feed.dropConditionalGetInfo()
|
||||
container.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addOrUpdateContainer(_ record: CKRecord) {
|
||||
guard let account = account,
|
||||
let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String,
|
||||
let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String,
|
||||
isAccount != "1" else {
|
||||
return
|
||||
}
|
||||
|
||||
var folder = account.existingFolder(withExternalID: record.externalID)
|
||||
folder?.name = name
|
||||
|
||||
if folder == nil {
|
||||
folder = account.ensureFolder(with: name)
|
||||
folder?.externalID = record.externalID
|
||||
}
|
||||
|
||||
guard let container = folder, let containerExternalID = container.externalID else { return }
|
||||
|
||||
if let newUnclaimedFeeds = newUnclaimedFeeds[containerExternalID] {
|
||||
for newUnclaimedFeed in newUnclaimedFeeds {
|
||||
createFeedIfNecessary(url: newUnclaimedFeed.url,
|
||||
name: newUnclaimedFeed.name,
|
||||
editedName: newUnclaimedFeed.editedName,
|
||||
homePageURL: newUnclaimedFeed.homePageURL,
|
||||
feedExternalID: newUnclaimedFeed.feedExternalID,
|
||||
container: container)
|
||||
}
|
||||
|
||||
self.newUnclaimedFeeds.removeValue(forKey: containerExternalID)
|
||||
}
|
||||
|
||||
if let existingUnclaimedFeeds = existingUnclaimedFeeds[containerExternalID] {
|
||||
for existingUnclaimedFeed in existingUnclaimedFeeds {
|
||||
container.addFeed(existingUnclaimedFeed)
|
||||
}
|
||||
self.existingUnclaimedFeeds.removeValue(forKey: containerExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeContainer(_ externalID: String) {
|
||||
if let folder = account?.existingFolder(withExternalID: externalID) {
|
||||
account?.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitAcountZoneDelegate {
|
||||
|
||||
func updateFeed(_ feed: Feed, name: String?, editedName: String?, homePageURL: String?, containerExternalIDs: [String]) {
|
||||
guard let account = account else { return }
|
||||
|
||||
feed.name = name
|
||||
feed.editedName = editedName
|
||||
feed.homePageURL = homePageURL
|
||||
|
||||
let existingContainers = account.existingContainers(withFeed: feed)
|
||||
let existingContainerExternalIds = existingContainers.compactMap { $0.externalID }
|
||||
|
||||
let diff = containerExternalIDs.difference(from: existingContainerExternalIds)
|
||||
|
||||
for change in diff {
|
||||
switch change {
|
||||
case .remove(_, let externalID, _):
|
||||
if let container = existingContainers.first(where: { $0.externalID == externalID }) {
|
||||
container.removeFeed(feed)
|
||||
}
|
||||
case .insert(_, let externalID, _):
|
||||
if let container = account.existingContainer(withExternalID: externalID) {
|
||||
container.addFeed(feed)
|
||||
} else {
|
||||
addExistingUnclaimedFeed(feed, containerExternalID: externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createFeedIfNecessary(url: URL, name: String?, editedName: String?, homePageURL: String?, feedExternalID: String, container: Container) {
|
||||
guard let account = account else { return }
|
||||
|
||||
if account.existingFeed(withExternalID: feedExternalID) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let feed = account.createFeed(with: name, url: url.absoluteString, feedID: url.absoluteString, homePageURL: homePageURL)
|
||||
feed.editedName = editedName
|
||||
feed.externalID = feedExternalID
|
||||
container.addFeed(feed)
|
||||
}
|
||||
|
||||
func addNewUnclaimedFeed(url: URL, name: String?, editedName: String?, homePageURL: String?, feedExternalID: String, containerExternalID: String) {
|
||||
if var unclaimedFeeds = self.newUnclaimedFeeds[containerExternalID] {
|
||||
unclaimedFeeds.append(UnclaimedFeed(url: url, name: name, editedName: editedName, homePageURL: homePageURL, feedExternalID: feedExternalID))
|
||||
self.newUnclaimedFeeds[containerExternalID] = unclaimedFeeds
|
||||
} else {
|
||||
var unclaimedFeeds = [UnclaimedFeed]()
|
||||
unclaimedFeeds.append(UnclaimedFeed(url: url, name: name, editedName: editedName, homePageURL: homePageURL, feedExternalID: feedExternalID))
|
||||
self.newUnclaimedFeeds[containerExternalID] = unclaimedFeeds
|
||||
}
|
||||
}
|
||||
|
||||
func addExistingUnclaimedFeed(_ feed: Feed, containerExternalID: String) {
|
||||
if var unclaimedFeeds = self.existingUnclaimedFeeds[containerExternalID] {
|
||||
unclaimedFeeds.append(feed)
|
||||
self.existingUnclaimedFeeds[containerExternalID] = unclaimedFeeds
|
||||
} else {
|
||||
var unclaimedFeeds = [Feed]()
|
||||
unclaimedFeeds.append(feed)
|
||||
self.existingUnclaimedFeeds[containerExternalID] = unclaimedFeeds
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// CloudKitArticleStatusUpdate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/29/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SyncDatabase
|
||||
import Articles
|
||||
|
||||
struct CloudKitArticleStatusUpdate {
|
||||
|
||||
enum Record {
|
||||
case all
|
||||
case new
|
||||
case statusOnly
|
||||
case delete
|
||||
}
|
||||
|
||||
var articleID: String
|
||||
var statuses: [SyncStatus]
|
||||
var article: Article?
|
||||
|
||||
init?(articleID: String, statuses: [SyncStatus], article: Article?) {
|
||||
self.articleID = articleID
|
||||
self.statuses = statuses
|
||||
self.article = article
|
||||
|
||||
let rec = record
|
||||
// This is an invalid status update. The article is required for new and all
|
||||
if article == nil && (rec == .all || rec == .new) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var record: Record {
|
||||
if statuses.contains(where: { $0.key == .deleted }) {
|
||||
return .delete
|
||||
}
|
||||
|
||||
if statuses.count == 1, statuses.first!.key == .new {
|
||||
return .new
|
||||
}
|
||||
|
||||
if let article = article {
|
||||
if article.status.read == false || article.status.starred == true {
|
||||
return .all
|
||||
}
|
||||
}
|
||||
|
||||
return .statusOnly
|
||||
}
|
||||
|
||||
var isRead: Bool {
|
||||
if let article = article {
|
||||
return article.status.read
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var isStarred: Bool {
|
||||
if let article = article {
|
||||
return article.status.starred
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// CloudKitArticlesZone.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import Parser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import Articles
|
||||
import SyncDatabase
|
||||
|
||||
final class CloudKitArticlesZone: CloudKitZone {
|
||||
|
||||
var zoneID: CKRecordZone.ID
|
||||
|
||||
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var container: CKContainer?
|
||||
weak var database: CKDatabase?
|
||||
var delegate: CloudKitZoneDelegate? = nil
|
||||
|
||||
var compressionQueue = DispatchQueue(label: "Articles Zone Compression Queue")
|
||||
|
||||
struct CloudKitArticle {
|
||||
static let recordType = "Article"
|
||||
struct Fields {
|
||||
static let articleStatus = "articleStatus"
|
||||
static let feedURL = "webFeedURL"
|
||||
static let uniqueID = "uniqueID"
|
||||
static let title = "title"
|
||||
static let contentHTML = "contentHTML"
|
||||
static let contentHTMLData = "contentHTMLData"
|
||||
static let contentText = "contentText"
|
||||
static let contentTextData = "contentTextData"
|
||||
static let url = "url"
|
||||
static let externalURL = "externalURL"
|
||||
static let summary = "summary"
|
||||
static let imageURL = "imageURL"
|
||||
static let datePublished = "datePublished"
|
||||
static let dateModified = "dateModified"
|
||||
static let parsedAuthors = "parsedAuthors"
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudKitArticleStatus {
|
||||
static let recordType = "ArticleStatus"
|
||||
struct Fields {
|
||||
static let feedExternalID = "webFeedExternalID"
|
||||
static let read = "read"
|
||||
static let starred = "starred"
|
||||
}
|
||||
}
|
||||
|
||||
init(container: CKContainer) {
|
||||
self.container = container
|
||||
self.database = container.privateCloudDatabase
|
||||
self.zoneID = CKRecordZone.ID(zoneName: "Articles", ownerName: CKCurrentUserDefaultName)
|
||||
migrateChangeToken()
|
||||
}
|
||||
|
||||
func refreshArticles(completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
fetchChangesInZone() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticles(completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveNewArticles(_ articles: Set<Article>, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
guard !articles.isEmpty else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
var records = [CKRecord]()
|
||||
|
||||
let saveArticles = articles.filter { $0.status.read == false || $0.status.starred == true }
|
||||
for saveArticle in saveArticles {
|
||||
records.append(makeStatusRecord(saveArticle))
|
||||
records.append(makeArticleRecord(saveArticle))
|
||||
}
|
||||
|
||||
compressionQueue.async {
|
||||
let compressedRecords = self.compressArticleRecords(records)
|
||||
self.save(compressedRecords, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteArticles(_ feedExternalID: String, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
let predicate = NSPredicate(format: "webFeedExternalID = %@", feedExternalID)
|
||||
let ckQuery = CKQuery(recordType: CloudKitArticleStatus.recordType, predicate: predicate)
|
||||
delete(ckQuery: ckQuery, completion: completion)
|
||||
}
|
||||
|
||||
func modifyArticles(_ statusUpdates: [CloudKitArticleStatusUpdate], completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
guard !statusUpdates.isEmpty else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
var modifyRecords = [CKRecord]()
|
||||
var newRecords = [CKRecord]()
|
||||
var deleteRecordIDs = [CKRecord.ID]()
|
||||
|
||||
for statusUpdate in statusUpdates {
|
||||
switch statusUpdate.record {
|
||||
case .all:
|
||||
modifyRecords.append(self.makeStatusRecord(statusUpdate))
|
||||
modifyRecords.append(self.makeArticleRecord(statusUpdate.article!))
|
||||
case .new:
|
||||
newRecords.append(self.makeStatusRecord(statusUpdate))
|
||||
newRecords.append(self.makeArticleRecord(statusUpdate.article!))
|
||||
case .delete:
|
||||
deleteRecordIDs.append(CKRecord.ID(recordName: self.statusID(statusUpdate.articleID), zoneID: zoneID))
|
||||
case .statusOnly:
|
||||
modifyRecords.append(self.makeStatusRecord(statusUpdate))
|
||||
deleteRecordIDs.append(CKRecord.ID(recordName: self.articleID(statusUpdate.articleID), zoneID: zoneID))
|
||||
}
|
||||
}
|
||||
|
||||
compressionQueue.async {
|
||||
let compressedModifyRecords = self.compressArticleRecords(modifyRecords)
|
||||
self.modify(recordsToSave: compressedModifyRecords, recordIDsToDelete: deleteRecordIDs) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
let compressedNewRecords = self.compressArticleRecords(newRecords)
|
||||
self.saveIfNew(compressedNewRecords) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
self.handleModifyArticlesError(error, statusUpdates: statusUpdates, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitArticlesZone {
|
||||
|
||||
func handleModifyArticlesError(_ error: Error, statusUpdates: [CloudKitArticleStatusUpdate], completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.modifyArticles(statusUpdates, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func statusID(_ id: String) -> String {
|
||||
return "s|\(id)"
|
||||
}
|
||||
|
||||
func articleID(_ id: String) -> String {
|
||||
return "a|\(id)"
|
||||
}
|
||||
|
||||
func makeStatusRecord(_ article: Article) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: statusID(article.articleID), zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitArticleStatus.recordType, recordID: recordID)
|
||||
if let feedExternalID = article.feed?.externalID {
|
||||
record[CloudKitArticleStatus.Fields.feedExternalID] = feedExternalID
|
||||
}
|
||||
record[CloudKitArticleStatus.Fields.read] = article.status.read ? "1" : "0"
|
||||
record[CloudKitArticleStatus.Fields.starred] = article.status.starred ? "1" : "0"
|
||||
return record
|
||||
}
|
||||
|
||||
func makeStatusRecord(_ statusUpdate: CloudKitArticleStatusUpdate) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: statusID(statusUpdate.articleID), zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitArticleStatus.recordType, recordID: recordID)
|
||||
|
||||
if let feedExternalID = statusUpdate.article?.feed?.externalID {
|
||||
record[CloudKitArticleStatus.Fields.feedExternalID] = feedExternalID
|
||||
}
|
||||
|
||||
record[CloudKitArticleStatus.Fields.read] = statusUpdate.isRead ? "1" : "0"
|
||||
record[CloudKitArticleStatus.Fields.starred] = statusUpdate.isStarred ? "1" : "0"
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func makeArticleRecord(_ article: Article) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: articleID(article.articleID), zoneID: zoneID)
|
||||
let record = CKRecord(recordType: CloudKitArticle.recordType, recordID: recordID)
|
||||
|
||||
let articleStatusRecordID = CKRecord.ID(recordName: statusID(article.articleID), zoneID: zoneID)
|
||||
record[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf)
|
||||
record[CloudKitArticle.Fields.feedURL] = article.feed?.url
|
||||
record[CloudKitArticle.Fields.uniqueID] = article.uniqueID
|
||||
record[CloudKitArticle.Fields.title] = article.title
|
||||
record[CloudKitArticle.Fields.contentHTML] = article.contentHTML
|
||||
record[CloudKitArticle.Fields.contentText] = article.contentText
|
||||
record[CloudKitArticle.Fields.url] = article.rawLink
|
||||
record[CloudKitArticle.Fields.externalURL] = article.rawExternalLink
|
||||
record[CloudKitArticle.Fields.summary] = article.summary
|
||||
record[CloudKitArticle.Fields.imageURL] = article.rawImageLink
|
||||
record[CloudKitArticle.Fields.datePublished] = article.datePublished
|
||||
record[CloudKitArticle.Fields.dateModified] = article.dateModified
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
var parsedAuthors = [String]()
|
||||
|
||||
if let authors = article.authors, !authors.isEmpty {
|
||||
for author in authors {
|
||||
let parsedAuthor = ParsedAuthor(name: author.name,
|
||||
url: author.url,
|
||||
avatarURL: author.avatarURL,
|
||||
emailAddress: author.emailAddress)
|
||||
if let data = try? encoder.encode(parsedAuthor), let encodedParsedAuthor = String(data: data, encoding: .utf8) {
|
||||
parsedAuthors.append(encodedParsedAuthor)
|
||||
}
|
||||
}
|
||||
record[CloudKitArticle.Fields.parsedAuthors] = parsedAuthors
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func compressArticleRecords(_ records: [CKRecord]) -> [CKRecord] {
|
||||
var result = [CKRecord]()
|
||||
|
||||
for record in records {
|
||||
|
||||
if record.recordType == CloudKitArticle.recordType {
|
||||
|
||||
if let contentHTML = record[CloudKitArticle.Fields.contentHTML] as? String {
|
||||
let data = Data(contentHTML.utf8) as NSData
|
||||
if let compressedData = try? data.compressed(using: .lzfse) {
|
||||
record[CloudKitArticle.Fields.contentHTMLData] = compressedData as Data
|
||||
record[CloudKitArticle.Fields.contentHTML] = nil
|
||||
}
|
||||
}
|
||||
|
||||
if let contentText = record[CloudKitArticle.Fields.contentText] as? String {
|
||||
let data = Data(contentText.utf8) as NSData
|
||||
if let compressedData = try? data.compressed(using: .lzfse) {
|
||||
record[CloudKitArticle.Fields.contentTextData] = compressedData as Data
|
||||
record[CloudKitArticle.Fields.contentText] = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
result.append(record)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//
|
||||
// CloudKitArticlesZoneDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import Parser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import SyncDatabase
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
||||
class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var account: Account?
|
||||
var database: SyncDatabase
|
||||
weak var articlesZone: CloudKitArticlesZone?
|
||||
var compressionQueue = DispatchQueue(label: "Articles Zone Delegate Compression Queue")
|
||||
|
||||
init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone) {
|
||||
self.account = account
|
||||
self.database = database
|
||||
self.articlesZone = articlesZone
|
||||
}
|
||||
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
database.selectPendingReadStatusArticleIDs() { result in
|
||||
switch result {
|
||||
case .success(let pendingReadStatusArticleIDs):
|
||||
|
||||
self.database.selectPendingStarredStatusArticleIDs() { result in
|
||||
switch result {
|
||||
case .success(let pendingStarredStatusArticleIDs):
|
||||
|
||||
self.delete(recordKeys: deleted, pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) {
|
||||
self.update(records: changed,
|
||||
pendingReadStatusArticleIDs: pendingReadStatusArticleIDs,
|
||||
pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error occurred getting pending starred records: %@", error.localizedDescription)
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error occurred getting pending read status records: %@", error.localizedDescription)
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitArticlesZoneDelegate {
|
||||
|
||||
func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set<String>, completion: @escaping () -> Void) {
|
||||
let receivedRecordIDs = recordKeys.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticleStatus.recordType }).map({ $0.recordID })
|
||||
let receivedArticleIDs = Set(receivedRecordIDs.map({ stripPrefix($0.externalID) }))
|
||||
let deletableArticleIDs = receivedArticleIDs.subtracting(pendingStarredStatusArticleIDs)
|
||||
|
||||
guard !deletableArticleIDs.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
database.deleteSelectedForProcessing(Array(deletableArticleIDs)) { _ in
|
||||
self.account?.delete(articleIDs: deletableArticleIDs) { _ in
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(records: [CKRecord], pendingReadStatusArticleIDs: Set<String>, pendingStarredStatusArticleIDs: Set<String>, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
let receivedUnreadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ stripPrefix($0.externalID) }))
|
||||
let receivedReadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ stripPrefix($0.externalID) }))
|
||||
let receivedUnstarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ stripPrefix($0.externalID) }))
|
||||
let receivedStarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ stripPrefix($0.externalID) }))
|
||||
|
||||
let updateableUnreadArticleIDs = receivedUnreadArticleIDs.subtracting(pendingReadStatusArticleIDs)
|
||||
let updateableReadArticleIDs = receivedReadArticleIDs.subtracting(pendingReadStatusArticleIDs)
|
||||
let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs)
|
||||
let updateableStarredArticleIDs = receivedStarredArticleIDs.subtracting(pendingStarredStatusArticleIDs)
|
||||
|
||||
var errorOccurred = false
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
account?.markAsUnread(updateableUnreadArticleIDs) { result in
|
||||
if case .failure(let databaseError) = result {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while storing unread statuses: %@", databaseError.localizedDescription)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsRead(updateableReadArticleIDs) { result in
|
||||
if case .failure(let databaseError) = result {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while storing read statuses: %@", databaseError.localizedDescription)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsUnstarred(updateableUnstarredArticleIDs) { result in
|
||||
if case .failure(let databaseError) = result {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while storing unstarred statuses: %@", databaseError.localizedDescription)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsStarred(updateableStarredArticleIDs) { result in
|
||||
if case .failure(let databaseError) = result {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while storing starred statuses: %@", databaseError.localizedDescription)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
compressionQueue.async {
|
||||
let parsedItems = records.compactMap { self.makeParsedItem($0) }
|
||||
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
for (feedID, parsedItems) in feedIDsAndItems {
|
||||
group.enter()
|
||||
self.account?.update(feedID, with: parsedItems, deleteOlder: false) { result in
|
||||
switch result {
|
||||
case .success(let articleChanges):
|
||||
guard let deletes = articleChanges.deletedArticles, !deletes.isEmpty else {
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
let syncStatuses = deletes.map { SyncStatus(articleID: $0.articleID, key: .deleted, flag: true) }
|
||||
self.database.insertStatuses(syncStatuses) { _ in
|
||||
group.leave()
|
||||
}
|
||||
case .failure(let databaseError):
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while storing articles: %@", databaseError.localizedDescription)
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stripPrefix(_ externalID: String) -> String {
|
||||
return String(externalID[externalID.index(externalID.startIndex, offsetBy: 2)..<externalID.endIndex])
|
||||
}
|
||||
|
||||
func makeParsedItem(_ articleRecord: CKRecord) -> ParsedItem? {
|
||||
guard articleRecord.recordType == CloudKitArticlesZone.CloudKitArticle.recordType else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedAuthors = Set<ParsedAuthor>()
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
if let encodedParsedAuthors = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.parsedAuthors] as? [String] {
|
||||
for encodedParsedAuthor in encodedParsedAuthors {
|
||||
if let data = encodedParsedAuthor.data(using: .utf8), let parsedAuthor = try? decoder.decode(ParsedAuthor.self, from: data) {
|
||||
parsedAuthors.insert(parsedAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let uniqueID = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.uniqueID] as? String,
|
||||
let feedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.feedURL] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var contentHTML = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTML] as? String
|
||||
if let contentHTMLData = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTMLData] as? NSData {
|
||||
if let decompressedContentHTMLData = try? contentHTMLData.decompressed(using: .lzfse) {
|
||||
contentHTML = String(data: decompressedContentHTMLData as Data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
var contentText = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentText] as? String
|
||||
if let contentTextData = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentTextData] as? NSData {
|
||||
if let decompressedContentTextData = try? contentTextData.decompressed(using: .lzfse) {
|
||||
contentText = String(data: decompressedContentTextData as Data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
let parsedItem = ParsedItem(syncServiceID: nil,
|
||||
uniqueID: uniqueID,
|
||||
feedURL: feedURL,
|
||||
url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String,
|
||||
externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String,
|
||||
title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String,
|
||||
language: nil,
|
||||
contentHTML: contentHTML,
|
||||
contentText: contentText,
|
||||
summary: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.summary] as? String,
|
||||
imageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String,
|
||||
bannerImageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String,
|
||||
datePublished: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.datePublished] as? Date,
|
||||
dateModified: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.dateModified] as? Date,
|
||||
authors: parsedAuthors,
|
||||
tags: nil,
|
||||
attachments: nil)
|
||||
|
||||
return parsedItem
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// CloudKitReceiveStatusOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/2/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
|
||||
class CloudKitReceiveStatusOperation: MainThreadOperation {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
// MainThreadOperation
|
||||
public var isCanceled = false
|
||||
public var id: Int?
|
||||
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String? = "CloudKitReceiveStatusOperation"
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
private weak var articlesZone: CloudKitArticlesZone?
|
||||
|
||||
init(articlesZone: CloudKitArticlesZone) {
|
||||
self.articlesZone = articlesZone
|
||||
}
|
||||
|
||||
func run() {
|
||||
guard let articlesZone = articlesZone else {
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Refreshing article statuses...")
|
||||
|
||||
articlesZone.refreshArticles() { result in
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
switch result {
|
||||
case .success:
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Receive status error: %@.", error.localizedDescription)
|
||||
self.operationDelegate?.cancelOperation(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// CloudKitRemoteNotificationOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/2/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import os.log
|
||||
import RSCore
|
||||
|
||||
class CloudKitRemoteNotificationOperation: MainThreadOperation {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
// MainThreadOperation
|
||||
public var isCanceled = false
|
||||
public var id: Int?
|
||||
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String? = "CloudKitRemoteNotificationOperation"
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
private weak var accountZone: CloudKitAccountZone?
|
||||
private weak var articlesZone: CloudKitArticlesZone?
|
||||
private var userInfo: [AnyHashable : Any]
|
||||
|
||||
init(accountZone: CloudKitAccountZone, articlesZone: CloudKitArticlesZone, userInfo: [AnyHashable : Any]) {
|
||||
self.accountZone = accountZone
|
||||
self.articlesZone = articlesZone
|
||||
self.userInfo = userInfo
|
||||
}
|
||||
|
||||
func run() {
|
||||
guard let accountZone = accountZone, let articlesZone = articlesZone else {
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Processing remote notification...")
|
||||
|
||||
accountZone.receiveRemoteNotification(userInfo: userInfo) {
|
||||
articlesZone.receiveRemoteNotification(userInfo: self.userInfo) {
|
||||
os_log(.debug, log: self.log, "Done processing remote notification.")
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// CloudKitSendStatusOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/2/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
|
||||
class CloudKitSendStatusOperation: MainThreadOperation {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
private let blockSize = 150
|
||||
|
||||
// MainThreadOperation
|
||||
public var isCanceled = false
|
||||
public var id: Int?
|
||||
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String? = "CloudKitSendStatusOperation"
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
private weak var account: Account?
|
||||
private weak var articlesZone: CloudKitArticlesZone?
|
||||
private weak var refreshProgress: DownloadProgress?
|
||||
private var showProgress: Bool
|
||||
private var database: SyncDatabase
|
||||
|
||||
init(account: Account, articlesZone: CloudKitArticlesZone, refreshProgress: DownloadProgress, showProgress: Bool, database: SyncDatabase) {
|
||||
self.account = account
|
||||
self.articlesZone = articlesZone
|
||||
self.refreshProgress = refreshProgress
|
||||
self.showProgress = showProgress
|
||||
self.database = database
|
||||
}
|
||||
|
||||
func run() {
|
||||
os_log(.debug, log: log, "Sending article statuses...")
|
||||
|
||||
if showProgress {
|
||||
|
||||
database.selectPendingCount() { result in
|
||||
switch result {
|
||||
case .success(let count):
|
||||
let ticks = count / self.blockSize
|
||||
self.refreshProgress?.addToNumberOfTasksAndRemaining(ticks)
|
||||
self.selectForProcessing()
|
||||
case .failure(let databaseError):
|
||||
os_log(.error, log: self.log, "Send status count pending error: %@.", databaseError.localizedDescription)
|
||||
self.operationDelegate?.cancelOperation(self)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
selectForProcessing()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitSendStatusOperation {
|
||||
|
||||
func selectForProcessing() {
|
||||
database.selectForProcessing(limit: blockSize) { result in
|
||||
switch result {
|
||||
case .success(let syncStatuses):
|
||||
|
||||
func stopProcessing() {
|
||||
if self.showProgress {
|
||||
self.refreshProgress?.completeTask()
|
||||
}
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
|
||||
guard syncStatuses.count > 0 else {
|
||||
stopProcessing()
|
||||
return
|
||||
}
|
||||
|
||||
self.processStatuses(syncStatuses) { stop in
|
||||
if stop {
|
||||
stopProcessing()
|
||||
} else {
|
||||
self.selectForProcessing()
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let databaseError):
|
||||
os_log(.error, log: self.log, "Send status error: %@.", databaseError.localizedDescription)
|
||||
self.operationDelegate?.cancelOperation(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processStatuses(_ syncStatuses: [SyncStatus], completion: @escaping (Bool) -> Void) {
|
||||
guard let account = account, let articlesZone = articlesZone else {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
|
||||
let articleIDs = syncStatuses.map({ $0.articleID })
|
||||
account.fetchArticlesAsync(.articleIDs(Set(articleIDs))) { result in
|
||||
|
||||
func processWithArticles(_ articles: Set<Article>) {
|
||||
|
||||
let syncStatusesDict = Dictionary(grouping: syncStatuses, by: { $0.articleID })
|
||||
let articlesDict = articles.reduce(into: [String: Article]()) { result, article in
|
||||
result[article.articleID] = article
|
||||
}
|
||||
let statusUpdates = syncStatusesDict.compactMap { (key, value) in
|
||||
return CloudKitArticleStatusUpdate(articleID: key, statuses: value, article: articlesDict[key])
|
||||
}
|
||||
|
||||
func done(_ stop: Bool) {
|
||||
// Don't clear the last one since we might have had additional ticks added
|
||||
if self.showProgress && self.refreshProgress?.numberRemaining ?? 0 > 1 {
|
||||
self.refreshProgress?.completeTask()
|
||||
}
|
||||
os_log(.debug, log: self.log, "Done sending article status block...")
|
||||
completion(stop)
|
||||
}
|
||||
|
||||
// If this happens, we have somehow gotten into a state where we have new status records
|
||||
// but the articles didn't come back in the fetch. We need to clean up those sync records
|
||||
// and stop processing.
|
||||
if statusUpdates.isEmpty {
|
||||
self.database.deleteSelectedForProcessing(articleIDs) { _ in
|
||||
done(true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
articlesZone.modifyArticles(statusUpdates) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.database.deleteSelectedForProcessing(statusUpdates.map({ $0.articleID })) { _ in
|
||||
done(false)
|
||||
}
|
||||
case .failure(let error):
|
||||
self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID })) { _ in
|
||||
self.processAccountError(account, error)
|
||||
os_log(.error, log: self.log, "Send article status modify articles error: %@.", error.localizedDescription)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let articles):
|
||||
processWithArticles(articles)
|
||||
case .failure(let databaseError):
|
||||
self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID })) { _ in
|
||||
os_log(.error, log: self.log, "Send article status fetch articles error: %@.", databaseError.localizedDescription)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func processAccountError(_ account: Account, _ error: Error) {
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
account.removeFeeds(account.topLevelFeeds)
|
||||
for folder in account.folders ?? Set<Folder>() {
|
||||
account.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Maurice Parker on 9/22/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct CloudKitWebDocumentation {
|
||||
public static let limitationsAndSolutionsText = NSLocalizedString("iCloud Syncing Limitations & Solutions", comment: "iCloud Documentation")
|
||||
public static let limitationsAndSolutionsURL = URL(string: "https://netnewswire.com/help/iCloud")!
|
||||
}
|
||||
110
Modules/Account/Sources/Account/CombinedRefreshProgress.swift
Normal file
110
Modules/Account/Sources/Account/CombinedRefreshProgress.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// CombinedRefreshProgress.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/7/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
extension Notification.Name {
|
||||
public static let combinedRefreshProgressDidChange = Notification.Name("combinedRefreshProgressDidChange")
|
||||
}
|
||||
|
||||
/// Combine the refresh progress of multiple accounts into one place,
|
||||
/// for use by refresh status view and so on.
|
||||
public final class CombinedRefreshProgress {
|
||||
|
||||
public private(set) var numberOfTasks = 0
|
||||
public private(set) var numberRemaining = 0
|
||||
public private(set) var numberCompleted = 0
|
||||
|
||||
public var isComplete: Bool {
|
||||
!isStarted || numberRemaining < 1
|
||||
}
|
||||
|
||||
var isStarted = false
|
||||
|
||||
init() {
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(refreshProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil)
|
||||
}
|
||||
|
||||
func start() {
|
||||
reset()
|
||||
isStarted = true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
reset()
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
@objc func refreshProgressDidChange(_ notification: Notification) {
|
||||
|
||||
guard isStarted else {
|
||||
return
|
||||
}
|
||||
|
||||
var updatedNumberOfTasks = 0
|
||||
var updatedNumberRemaining = 0
|
||||
var updatedNumberCompleted = 0
|
||||
|
||||
var didMakeChange = false
|
||||
|
||||
let downloadProgresses = AccountManager.shared.activeAccounts.map { $0.refreshProgress }
|
||||
for downloadProgress in downloadProgresses {
|
||||
updatedNumberOfTasks += downloadProgress.numberOfTasks
|
||||
updatedNumberRemaining += downloadProgress.numberRemaining
|
||||
updatedNumberCompleted += downloadProgress.numberCompleted
|
||||
}
|
||||
|
||||
if updatedNumberOfTasks > numberOfTasks {
|
||||
numberOfTasks = updatedNumberOfTasks
|
||||
didMakeChange = true
|
||||
}
|
||||
|
||||
assert(updatedNumberRemaining <= numberOfTasks)
|
||||
updatedNumberRemaining = max(updatedNumberRemaining, numberRemaining)
|
||||
updatedNumberRemaining = min(updatedNumberRemaining, numberOfTasks)
|
||||
if updatedNumberRemaining != numberRemaining {
|
||||
numberRemaining = updatedNumberRemaining
|
||||
didMakeChange = true
|
||||
}
|
||||
|
||||
assert(updatedNumberCompleted <= numberOfTasks)
|
||||
updatedNumberCompleted = max(updatedNumberCompleted, numberCompleted)
|
||||
updatedNumberCompleted = min(updatedNumberCompleted, numberOfTasks)
|
||||
if updatedNumberCompleted != numberCompleted {
|
||||
numberCompleted = updatedNumberCompleted
|
||||
didMakeChange = true
|
||||
}
|
||||
|
||||
if didMakeChange {
|
||||
postDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CombinedRefreshProgress {
|
||||
|
||||
func reset() {
|
||||
|
||||
let didMakeChange = numberOfTasks != 0 || numberRemaining != 0 || numberCompleted != 0
|
||||
|
||||
numberOfTasks = 0
|
||||
numberRemaining = 0
|
||||
numberCompleted = 0
|
||||
|
||||
if didMakeChange {
|
||||
postDidChangeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
func postDidChangeNotification() {
|
||||
|
||||
NotificationCenter.default.post(name: .combinedRefreshProgressDidChange, object: self)
|
||||
}
|
||||
}
|
||||
167
Modules/Account/Sources/Account/Container.swift
Normal file
167
Modules/Account/Sources/Account/Container.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
|
||||
//
|
||||
// Container.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 4/17/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Articles
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
public static let ChildrenDidChange = Notification.Name("ChildrenDidChange")
|
||||
}
|
||||
|
||||
public protocol Container: AnyObject, ContainerIdentifiable {
|
||||
|
||||
var account: Account? { get }
|
||||
var topLevelFeeds: Set<Feed> { get set }
|
||||
var folders: Set<Folder>? { get set }
|
||||
var externalID: String? { get set }
|
||||
|
||||
func hasAtLeastOneFeed() -> Bool
|
||||
func objectIsChild(_ object: AnyObject) -> Bool
|
||||
|
||||
func hasChildFolder(with: String) -> Bool
|
||||
func childFolder(with: String) -> Folder?
|
||||
|
||||
func removeFeed(_ feed: Feed)
|
||||
func addFeed(_ feed: Feed)
|
||||
|
||||
//Recursive — checks subfolders
|
||||
func flattenedFeeds() -> Set<Feed>
|
||||
func has(_ feed: Feed) -> Bool
|
||||
func hasFeed(with feedID: String) -> Bool
|
||||
func hasFeed(withURL url: String) -> Bool
|
||||
func existingFeed(withFeedID: String) -> Feed?
|
||||
func existingFeed(withURL url: String) -> Feed?
|
||||
func existingFeed(withExternalID externalID: String) -> Feed?
|
||||
func existingFolder(with name: String) -> Folder?
|
||||
func existingFolder(withID: Int) -> Folder?
|
||||
|
||||
func postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
public extension Container {
|
||||
|
||||
func hasAtLeastOneFeed() -> Bool {
|
||||
return topLevelFeeds.count > 0
|
||||
}
|
||||
|
||||
func hasChildFolder(with name: String) -> Bool {
|
||||
return childFolder(with: name) != nil
|
||||
}
|
||||
|
||||
func childFolder(with name: String) -> Folder? {
|
||||
guard let folders = folders else {
|
||||
return nil
|
||||
}
|
||||
for folder in folders {
|
||||
if folder.name == name {
|
||||
return folder
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func objectIsChild(_ object: AnyObject) -> Bool {
|
||||
if let feed = object as? Feed {
|
||||
return topLevelFeeds.contains(feed)
|
||||
}
|
||||
if let folder = object as? Folder {
|
||||
return folders?.contains(folder) ?? false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func flattenedFeeds() -> Set<Feed> {
|
||||
var feeds = Set<Feed>()
|
||||
feeds.formUnion(topLevelFeeds)
|
||||
if let folders = folders {
|
||||
for folder in folders {
|
||||
feeds.formUnion(folder.flattenedFeeds())
|
||||
}
|
||||
}
|
||||
return feeds
|
||||
}
|
||||
|
||||
func hasFeed(with feedID: String) -> Bool {
|
||||
return existingFeed(withFeedID: feedID) != nil
|
||||
}
|
||||
|
||||
func hasFeed(withURL url: String) -> Bool {
|
||||
return existingFeed(withURL: url) != nil
|
||||
}
|
||||
|
||||
func has(_ feed: Feed) -> Bool {
|
||||
return flattenedFeeds().contains(feed)
|
||||
}
|
||||
|
||||
func existingFeed(withFeedID feedID: String) -> Feed? {
|
||||
for feed in flattenedFeeds() {
|
||||
if feed.feedID == feedID {
|
||||
return feed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingFeed(withURL url: String) -> Feed? {
|
||||
for feed in flattenedFeeds() {
|
||||
if feed.url == url {
|
||||
return feed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingFeed(withExternalID externalID: String) -> Feed? {
|
||||
for feed in flattenedFeeds() {
|
||||
if feed.externalID == externalID {
|
||||
return feed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingFolder(with name: String) -> Folder? {
|
||||
guard let folders = folders else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for folder in folders {
|
||||
if folder.name == name {
|
||||
return folder
|
||||
}
|
||||
if let subFolder = folder.existingFolder(with: name) {
|
||||
return subFolder
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingFolder(withID folderID: Int) -> Folder? {
|
||||
guard let folders = folders else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for folder in folders {
|
||||
if folder.folderID == folderID {
|
||||
return folder
|
||||
}
|
||||
if let subFolder = folder.existingFolder(withID: folderID) {
|
||||
return subFolder
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postChildrenDidChangeNotification() {
|
||||
NotificationCenter.default.post(name: .ChildrenDidChange, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
101
Modules/Account/Sources/Account/ContainerIdentifier.swift
Normal file
101
Modules/Account/Sources/Account/ContainerIdentifier.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// ContainerIdentifier.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 11/24/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ContainerIdentifiable {
|
||||
var containerID: ContainerIdentifier? { get }
|
||||
}
|
||||
|
||||
public enum ContainerIdentifier: Hashable, Equatable {
|
||||
case smartFeedController
|
||||
case account(String) // accountID
|
||||
case folder(String, String) // accountID, folderName
|
||||
|
||||
public var userInfo: [AnyHashable: AnyHashable] {
|
||||
switch self {
|
||||
case .smartFeedController:
|
||||
return [
|
||||
"type": "smartFeedController"
|
||||
]
|
||||
case .account(let accountID):
|
||||
return [
|
||||
"type": "account",
|
||||
"accountID": accountID
|
||||
]
|
||||
case .folder(let accountID, let folderName):
|
||||
return [
|
||||
"type": "folder",
|
||||
"accountID": accountID,
|
||||
"folderName": folderName
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public init?(userInfo: [AnyHashable: AnyHashable]) {
|
||||
guard let type = userInfo["type"] as? String else { return nil }
|
||||
|
||||
switch type {
|
||||
case "smartFeedController":
|
||||
self = ContainerIdentifier.smartFeedController
|
||||
case "account":
|
||||
guard let accountID = userInfo["accountID"] as? String else { return nil }
|
||||
self = ContainerIdentifier.account(accountID)
|
||||
case "folder":
|
||||
guard let accountID = userInfo["accountID"] as? String, let folderName = userInfo["folderName"] as? String else { return nil }
|
||||
self = ContainerIdentifier.folder(accountID, folderName)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ContainerIdentifier: Encodable {
|
||||
enum CodingKeys: CodingKey {
|
||||
case type
|
||||
case accountID
|
||||
case folderName
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .smartFeedController:
|
||||
try container.encode("smartFeedController", forKey: .type)
|
||||
case .account(let accountID):
|
||||
try container.encode("account", forKey: .type)
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
case .folder(let accountID, let folderName):
|
||||
try container.encode("folder", forKey: .type)
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
try container.encode(folderName, forKey: .folderName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContainerIdentifier: Decodable {
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "smartFeedController":
|
||||
self = .smartFeedController
|
||||
case "account":
|
||||
let accountID = try container.decode(String.self, forKey: .accountID)
|
||||
self = .account(accountID)
|
||||
default:
|
||||
let accountID = try container.decode(String.self, forKey: .accountID)
|
||||
let folderName = try container.decode(String.self, forKey: .folderName)
|
||||
self = .folder(accountID, folderName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
49
Modules/Account/Sources/Account/ContainerPath.swift
Normal file
49
Modules/Account/Sources/Account/ContainerPath.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// ContainerPath.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 11/4/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Used to identify the parent of an object.
|
||||
// Mainly used with deleting objects and undo/redo.
|
||||
// Especially redo. The idea is to put something back in the right place.
|
||||
|
||||
public struct ContainerPath {
|
||||
|
||||
private weak var account: Account?
|
||||
private let names: [String] // empty if top-level of account
|
||||
private let folderID: Int? // nil if top-level
|
||||
private let isTopLevel: Bool
|
||||
|
||||
// folders should be from top-level down, as in ["Cats", "Tabbies"]
|
||||
|
||||
public init(account: Account, folders: [Folder]) {
|
||||
self.account = account
|
||||
self.names = folders.map { $0.nameForDisplay }
|
||||
self.isTopLevel = folders.isEmpty
|
||||
|
||||
self.folderID = folders.last?.folderID
|
||||
}
|
||||
|
||||
public func resolveContainer() -> Container? {
|
||||
// The only time it should fail is if the account no longer exists.
|
||||
// Otherwise the worst-case scenario is that it will create Folders if needed.
|
||||
|
||||
guard let account = account else {
|
||||
return nil
|
||||
}
|
||||
if isTopLevel {
|
||||
return account
|
||||
}
|
||||
|
||||
if let folderID = folderID, let folder = account.existingFolder(withID: folderID) {
|
||||
return folder
|
||||
}
|
||||
|
||||
return account.ensureFolder(withFolderNames: names)
|
||||
}
|
||||
}
|
||||
64
Modules/Account/Sources/Account/DataExtensions.swift
Normal file
64
Modules/Account/Sources/Account/DataExtensions.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// DataExtensions.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/7/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Parser
|
||||
|
||||
public extension Notification.Name {
|
||||
static let feedSettingDidChange = Notification.Name(rawValue: "FeedSettingDidChangeNotification")
|
||||
}
|
||||
|
||||
public extension Feed {
|
||||
|
||||
static let FeedSettingUserInfoKey = "feedSetting"
|
||||
|
||||
struct FeedSettingKey {
|
||||
public static let homePageURL = "homePageURL"
|
||||
public static let iconURL = "iconURL"
|
||||
public static let faviconURL = "faviconURL"
|
||||
public static let name = "name"
|
||||
public static let editedName = "editedName"
|
||||
public static let authors = "authors"
|
||||
public static let contentHash = "contentHash"
|
||||
public static let conditionalGetInfo = "conditionalGetInfo"
|
||||
public static let cacheControlInfo = "cacheControlInfo"
|
||||
}
|
||||
}
|
||||
|
||||
extension Feed {
|
||||
|
||||
func takeSettings(from parsedFeed: ParsedFeed) {
|
||||
iconURL = parsedFeed.iconURL
|
||||
faviconURL = parsedFeed.faviconURL
|
||||
homePageURL = parsedFeed.homePageURL
|
||||
name = parsedFeed.title
|
||||
authors = Author.authorsWithParsedAuthors(parsedFeed.authors)
|
||||
}
|
||||
|
||||
func postFeedSettingDidChangeNotification(_ codingKey: FeedMetadata.CodingKeys) {
|
||||
let userInfo = [Feed.FeedSettingUserInfoKey: codingKey.stringValue]
|
||||
NotificationCenter.default.post(name: .feedSettingDidChange, object: self, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
public extension Article {
|
||||
|
||||
var account: Account? {
|
||||
// The force unwrapped shared instance was crashing Account.framework unit tests.
|
||||
guard let manager = AccountManager.shared else {
|
||||
return nil
|
||||
}
|
||||
return manager.existingAccount(with: accountID)
|
||||
}
|
||||
|
||||
var feed: Feed? {
|
||||
return account?.existingFeed(withFeedID: feedID)
|
||||
}
|
||||
}
|
||||
|
||||
321
Modules/Account/Sources/Account/Feed.swift
Normal file
321
Modules/Account/Sources/Account/Feed.swift
Normal file
@@ -0,0 +1,321 @@
|
||||
//
|
||||
// Feed.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Articles
|
||||
|
||||
public final class Feed: SidebarItem, Renamable, Hashable {
|
||||
|
||||
public var defaultReadFilterType: ReadFilterType {
|
||||
return .none
|
||||
}
|
||||
|
||||
public var sidebarItemID: SidebarItemIdentifier? {
|
||||
guard let accountID = account?.accountID else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
return nil
|
||||
}
|
||||
return SidebarItemIdentifier.feed(accountID, feedID)
|
||||
}
|
||||
|
||||
public weak var account: Account?
|
||||
public let url: String
|
||||
|
||||
public var feedID: String {
|
||||
get {
|
||||
return metadata.feedID
|
||||
}
|
||||
set {
|
||||
metadata.feedID = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var homePageURL: String? {
|
||||
get {
|
||||
return metadata.homePageURL
|
||||
}
|
||||
set {
|
||||
if let url = newValue, !url.isEmpty {
|
||||
metadata.homePageURL = url.normalizedURL
|
||||
}
|
||||
else {
|
||||
metadata.homePageURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this is available only if the icon URL was available in the feed.
|
||||
// The icon URL is a JSON-Feed-only feature.
|
||||
// Otherwise we find an icon URL via other means, but we don’t store it
|
||||
// as part of feed metadata.
|
||||
public var iconURL: String? {
|
||||
get {
|
||||
return metadata.iconURL
|
||||
}
|
||||
set {
|
||||
metadata.iconURL = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this is available only if the favicon URL was available in the feed.
|
||||
// The favicon URL is a JSON-Feed-only feature.
|
||||
// Otherwise we find a favicon URL via other means, but we don’t store it
|
||||
// as part of feed metadata.
|
||||
public var faviconURL: String? {
|
||||
get {
|
||||
return metadata.faviconURL
|
||||
}
|
||||
set {
|
||||
metadata.faviconURL = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String? {
|
||||
didSet {
|
||||
if name != oldValue {
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var authors: Set<Author>? {
|
||||
get {
|
||||
if let authorsArray = metadata.authors {
|
||||
return Set(authorsArray)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
set {
|
||||
if let authorsSet = newValue {
|
||||
metadata.authors = Array(authorsSet)
|
||||
}
|
||||
else {
|
||||
metadata.authors = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var editedName: String? {
|
||||
// Don’t let editedName == ""
|
||||
get {
|
||||
guard let s = metadata.editedName, !s.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
set {
|
||||
if newValue != editedName {
|
||||
if let valueToSet = newValue, !valueToSet.isEmpty {
|
||||
metadata.editedName = valueToSet
|
||||
}
|
||||
else {
|
||||
metadata.editedName = nil
|
||||
}
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var conditionalGetInfo: HTTPConditionalGetInfo? {
|
||||
get {
|
||||
return metadata.conditionalGetInfo
|
||||
}
|
||||
set {
|
||||
metadata.conditionalGetInfo = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var cacheControlInfo: CacheControlInfo? {
|
||||
get {
|
||||
metadata.cacheControlInfo
|
||||
}
|
||||
set {
|
||||
metadata.cacheControlInfo = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var contentHash: String? {
|
||||
get {
|
||||
return metadata.contentHash
|
||||
}
|
||||
set {
|
||||
metadata.contentHash = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var isNotifyAboutNewArticles: Bool? {
|
||||
get {
|
||||
return metadata.isNotifyAboutNewArticles
|
||||
}
|
||||
set {
|
||||
metadata.isNotifyAboutNewArticles = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var isArticleExtractorAlwaysOn: Bool? {
|
||||
get {
|
||||
metadata.isArticleExtractorAlwaysOn
|
||||
}
|
||||
set {
|
||||
metadata.isArticleExtractorAlwaysOn = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var externalID: String? {
|
||||
get {
|
||||
return metadata.externalID
|
||||
}
|
||||
set {
|
||||
metadata.externalID = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Name: Sync Service Relationship ID
|
||||
public var folderRelationship: [String: String]? {
|
||||
get {
|
||||
return metadata.folderRelationship
|
||||
}
|
||||
set {
|
||||
metadata.folderRelationship = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DisplayNameProvider
|
||||
|
||||
public var nameForDisplay: String {
|
||||
if let s = editedName, !s.isEmpty {
|
||||
return s
|
||||
}
|
||||
if let s = name, !s.isEmpty {
|
||||
return s
|
||||
}
|
||||
return NSLocalizedString("Untitled", comment: "Feed name")
|
||||
}
|
||||
|
||||
// MARK: - Renamable
|
||||
|
||||
public func rename(to newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let account = account else { return }
|
||||
account.renameFeed(self, to: newName, completion: completion)
|
||||
}
|
||||
|
||||
// MARK: - UnreadCountProvider
|
||||
|
||||
public var unreadCount: Int {
|
||||
get {
|
||||
return account?.unreadCount(for: self) ?? 0
|
||||
}
|
||||
set {
|
||||
if unreadCount == newValue {
|
||||
return
|
||||
}
|
||||
account?.setUnreadCount(newValue, for: self)
|
||||
postUnreadCountDidChangeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NotificationDisplayName
|
||||
public var notificationDisplayName: String {
|
||||
#if os(macOS)
|
||||
if self.url.contains("www.reddit.com") {
|
||||
return NSLocalizedString("Show notifications for new posts", comment: "notifyNameDisplay / Reddit")
|
||||
} else {
|
||||
return NSLocalizedString("Show notifications for new articles", comment: "notifyNameDisplay / Default")
|
||||
}
|
||||
#else
|
||||
if self.url.contains("www.reddit.com") {
|
||||
return NSLocalizedString("Notify about new posts", comment: "notifyNameDisplay / Reddit")
|
||||
} else {
|
||||
return NSLocalizedString("Notify about new articles", comment: "notifyNameDisplay / Default")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var metadata: FeedMetadata
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let accountID: String // Used for hashing and equality; account may turn nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(account: Account, url: String, metadata: FeedMetadata) {
|
||||
self.account = account
|
||||
self.accountID = account.accountID
|
||||
self.url = url
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
public func dropConditionalGetInfo() {
|
||||
conditionalGetInfo = nil
|
||||
contentHash = nil
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(feedID)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
public class func ==(lhs: Feed, rhs: Feed) -> Bool {
|
||||
return lhs.feedID == rhs.feedID && lhs.accountID == rhs.accountID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OPMLRepresentable
|
||||
|
||||
extension Feed: OPMLRepresentable {
|
||||
|
||||
public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String {
|
||||
// https://github.com/brentsimmons/NetNewsWire/issues/527
|
||||
// Don’t use nameForDisplay because that can result in a feed name "Untitled" written to disk,
|
||||
// which NetNewsWire may take later to be the actual name.
|
||||
var nameToUse = editedName
|
||||
if nameToUse == nil {
|
||||
nameToUse = name
|
||||
}
|
||||
if nameToUse == nil {
|
||||
nameToUse = ""
|
||||
}
|
||||
let escapedName = nameToUse!.escapingSpecialXMLCharacters
|
||||
|
||||
var escapedHomePageURL = ""
|
||||
if let homePageURL = homePageURL {
|
||||
escapedHomePageURL = homePageURL.escapingSpecialXMLCharacters
|
||||
}
|
||||
let escapedFeedURL = url.escapingSpecialXMLCharacters
|
||||
|
||||
var s = "<outline text=\"\(escapedName)\" title=\"\(escapedName)\" description=\"\" type=\"rss\" version=\"RSS\" htmlUrl=\"\(escapedHomePageURL)\" xmlUrl=\"\(escapedFeedURL)\"/>\n"
|
||||
s = s.prepending(tabCount: indentLevel)
|
||||
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
extension Set where Element == Feed {
|
||||
|
||||
func feedIDs() -> Set<String> {
|
||||
return Set<String>(map { $0.feedID })
|
||||
}
|
||||
|
||||
func sorted() -> Array<Feed> {
|
||||
return sorted(by: { (feed1, feed2) -> Bool in
|
||||
if feed1.nameForDisplay.localizedStandardCompare(feed2.nameForDisplay) == .orderedSame {
|
||||
return feed1.url < feed2.url
|
||||
}
|
||||
return feed1.nameForDisplay.localizedStandardCompare(feed2.nameForDisplay) == .orderedAscending
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
169
Modules/Account/Sources/Account/FeedFinder/FeedFinder.swift
Normal file
169
Modules/Account/Sources/Account/FeedFinder/FeedFinder.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// FeedFinder.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/2/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
class FeedFinder {
|
||||
|
||||
static func find(url: URL, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
Downloader.shared.download(url) { (data, response, error) in
|
||||
|
||||
if response?.forcedStatusCode == 404 {
|
||||
if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), urlComponents.host == "micro.blog" {
|
||||
urlComponents.path = "\(urlComponents.path).json"
|
||||
if let newURLString = urlComponents.url?.absoluteString {
|
||||
let microblogFeedSpecifier = FeedSpecifier(title: nil, urlString: newURLString, source: .HTMLLink, orderFound: 1)
|
||||
completion(.success(Set([microblogFeedSpecifier])))
|
||||
}
|
||||
} else {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let response = response else {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if !response.statusIsOK || data.isEmpty {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if FeedFinder.isFeed(data) {
|
||||
let feedSpecifier = FeedSpecifier(title: nil, urlString: url.absoluteString, source: .UserEntered, orderFound: 1)
|
||||
completion(.success(Set([feedSpecifier])))
|
||||
return
|
||||
}
|
||||
|
||||
if !FeedFinder.isHTML(data) {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
FeedFinder.findFeedsInHTMLPage(htmlData: data, urlString: url.absoluteString, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedFinder {
|
||||
|
||||
static func addFeedSpecifier(_ feedSpecifier: FeedSpecifier, feedSpecifiers: inout [String: FeedSpecifier]) {
|
||||
// If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source.
|
||||
|
||||
if let existingFeedSpecifier = feedSpecifiers[feedSpecifier.urlString] {
|
||||
let mergedFeedSpecifier = existingFeedSpecifier.feedSpecifierByMerging(feedSpecifier)
|
||||
feedSpecifiers[feedSpecifier.urlString] = mergedFeedSpecifier
|
||||
}
|
||||
else {
|
||||
feedSpecifiers[feedSpecifier.urlString] = feedSpecifier
|
||||
}
|
||||
}
|
||||
|
||||
static func findFeedsInHTMLPage(htmlData: Data, urlString: String, completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
// Feeds in the <head> section we automatically assume are feeds.
|
||||
// If there are none from the <head> section,
|
||||
// then possible feeds in <body> section are downloaded individually
|
||||
// and added once we determine they are feeds.
|
||||
|
||||
let possibleFeedSpecifiers = possibleFeedsInHTMLPage(htmlData: htmlData, urlString: urlString)
|
||||
var feedSpecifiers = [String: FeedSpecifier]()
|
||||
var feedSpecifiersToDownload = Set<FeedSpecifier>()
|
||||
|
||||
var didFindFeedInHTMLHead = false
|
||||
|
||||
for oneFeedSpecifier in possibleFeedSpecifiers {
|
||||
if oneFeedSpecifier.source == .HTMLHead {
|
||||
addFeedSpecifier(oneFeedSpecifier, feedSpecifiers: &feedSpecifiers)
|
||||
didFindFeedInHTMLHead = true
|
||||
}
|
||||
else {
|
||||
if feedSpecifiers[oneFeedSpecifier.urlString] == nil {
|
||||
feedSpecifiersToDownload.insert(oneFeedSpecifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if didFindFeedInHTMLHead {
|
||||
completion(.success(Set(feedSpecifiers.values)))
|
||||
return
|
||||
}
|
||||
else if feedSpecifiersToDownload.isEmpty {
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
else {
|
||||
downloadFeedSpecifiers(feedSpecifiersToDownload, feedSpecifiers: feedSpecifiers, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
static func possibleFeedsInHTMLPage(htmlData: Data, urlString: String) -> Set<FeedSpecifier> {
|
||||
let parserData = ParserData(url: urlString, data: htmlData)
|
||||
var feedSpecifiers = HTMLFeedFinder(parserData: parserData).feedSpecifiers
|
||||
|
||||
if feedSpecifiers.isEmpty {
|
||||
// Odds are decent it’s a WordPress site, and just adding /feed/ will work.
|
||||
// It’s also fairly common for /index.xml to work.
|
||||
if let url = URL(string: urlString) {
|
||||
let feedURL = url.appendingPathComponent("feed", isDirectory: true)
|
||||
let wordpressFeedSpecifier = FeedSpecifier(title: nil, urlString: feedURL.absoluteString, source: .HTMLLink, orderFound: 1)
|
||||
feedSpecifiers.insert(wordpressFeedSpecifier)
|
||||
|
||||
let indexXMLURL = url.appendingPathComponent("index.xml", isDirectory: false)
|
||||
let indexXMLFeedSpecifier = FeedSpecifier(title: nil, urlString: indexXMLURL.absoluteString, source: .HTMLLink, orderFound: 1)
|
||||
feedSpecifiers.insert(indexXMLFeedSpecifier)
|
||||
}
|
||||
}
|
||||
|
||||
return feedSpecifiers
|
||||
}
|
||||
|
||||
static func isHTML(_ data: Data) -> Bool {
|
||||
return data.isProbablyHTML
|
||||
}
|
||||
|
||||
static func downloadFeedSpecifiers(_ downloadFeedSpecifiers: Set<FeedSpecifier>, feedSpecifiers: [String: FeedSpecifier], completion: @escaping (Result<Set<FeedSpecifier>, Error>) -> Void) {
|
||||
|
||||
var resultFeedSpecifiers = feedSpecifiers
|
||||
let group = DispatchGroup()
|
||||
|
||||
for downloadFeedSpecifier in downloadFeedSpecifiers {
|
||||
guard let url = URL(string: downloadFeedSpecifier.urlString) else {
|
||||
continue
|
||||
}
|
||||
|
||||
group.enter()
|
||||
Downloader.shared.download(url) { (data, response, error) in
|
||||
if let data = data, let response = response, response.statusIsOK, error == nil {
|
||||
if self.isFeed(data) {
|
||||
addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers)
|
||||
}
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion(.success(Set(resultFeedSpecifiers.values)))
|
||||
}
|
||||
}
|
||||
|
||||
static func isFeed(_ data: Data) -> Bool {
|
||||
return FeedParser.canParse(data)
|
||||
}
|
||||
}
|
||||
106
Modules/Account/Sources/Account/FeedFinder/FeedSpecifier.swift
Normal file
106
Modules/Account/Sources/Account/FeedFinder/FeedSpecifier.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// FeedSpecifier.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/7/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedSpecifier: Hashable {
|
||||
|
||||
enum Source: Int {
|
||||
case UserEntered = 0, HTMLHead, HTMLLink
|
||||
|
||||
func equalToOrBetterThan(_ otherSource: Source) -> Bool {
|
||||
return self.rawValue <= otherSource.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public let title: String?
|
||||
public let urlString: String
|
||||
public let source: Source
|
||||
public let orderFound: Int
|
||||
public var score: Int {
|
||||
return calculatedScore()
|
||||
}
|
||||
|
||||
func feedSpecifierByMerging(_ feedSpecifier: FeedSpecifier) -> FeedSpecifier {
|
||||
// Take the best data (non-nil title, better source) to create a new feed specifier;
|
||||
|
||||
let mergedTitle = title ?? feedSpecifier.title
|
||||
let mergedSource = source.equalToOrBetterThan(feedSpecifier.source) ? source : feedSpecifier.source
|
||||
let mergedOrderFound = orderFound < feedSpecifier.orderFound ? orderFound : feedSpecifier.orderFound
|
||||
|
||||
return FeedSpecifier(title: mergedTitle, urlString: urlString, source: mergedSource, orderFound: mergedOrderFound)
|
||||
}
|
||||
|
||||
public static func bestFeed(in feedSpecifiers: Set<FeedSpecifier>) -> FeedSpecifier? {
|
||||
if feedSpecifiers.isEmpty {
|
||||
return nil
|
||||
}
|
||||
if feedSpecifiers.count == 1 {
|
||||
return feedSpecifiers.anyObject()
|
||||
}
|
||||
|
||||
var currentHighScore = Int.min
|
||||
var currentBestFeed: FeedSpecifier? = nil
|
||||
|
||||
for oneFeedSpecifier in feedSpecifiers {
|
||||
let oneScore = oneFeedSpecifier.score
|
||||
if oneScore > currentHighScore {
|
||||
currentHighScore = oneScore
|
||||
currentBestFeed = oneFeedSpecifier
|
||||
}
|
||||
}
|
||||
|
||||
return currentBestFeed
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedSpecifier {
|
||||
|
||||
func calculatedScore() -> Int {
|
||||
var score = 0
|
||||
|
||||
if source == .UserEntered {
|
||||
return 1000
|
||||
}
|
||||
else if source == .HTMLHead {
|
||||
score = score + 50
|
||||
}
|
||||
|
||||
score = score - ((orderFound - 1) * 5)
|
||||
|
||||
if urlString.caseInsensitiveContains("comments") {
|
||||
score = score - 10
|
||||
}
|
||||
if urlString.caseInsensitiveContains("podcast") {
|
||||
score = score - 10
|
||||
}
|
||||
if urlString.caseInsensitiveContains("rss") {
|
||||
score = score + 5
|
||||
}
|
||||
if urlString.hasSuffix("/feed/") {
|
||||
score = score + 5
|
||||
}
|
||||
if urlString.hasSuffix("/feed") {
|
||||
score = score + 4
|
||||
}
|
||||
if urlString.caseInsensitiveContains("json") {
|
||||
score = score + 6
|
||||
}
|
||||
|
||||
if let title = title {
|
||||
if title.caseInsensitiveContains("comments") {
|
||||
score = score - 10
|
||||
}
|
||||
if title.caseInsensitiveContains("json") {
|
||||
score = score + 1
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// HTMLFeedFinder.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/7/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
|
||||
private let feedURLWordsToMatch = ["feed", "xml", "rss", "atom", "json"]
|
||||
|
||||
class HTMLFeedFinder {
|
||||
|
||||
var feedSpecifiers: Set<FeedSpecifier> {
|
||||
return Set(feedSpecifiersDictionary.values)
|
||||
}
|
||||
|
||||
private var feedSpecifiersDictionary = [String: FeedSpecifier]()
|
||||
|
||||
init(parserData: ParserData) {
|
||||
let metadata = HTMLMetadataParser.metadata(with: parserData)
|
||||
var orderFound = 0
|
||||
|
||||
if let feedLinks = metadata.feedLinks {
|
||||
for oneFeedLink in feedLinks {
|
||||
if let oneURLString = oneFeedLink.urlString?.normalizedURL {
|
||||
orderFound = orderFound + 1
|
||||
let oneFeedSpecifier = FeedSpecifier(title: oneFeedLink.title, urlString: oneURLString, source: .HTMLHead, orderFound: orderFound)
|
||||
addFeedSpecifier(oneFeedSpecifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bodyLinks = HTMLLinkParser.htmlLinks(with: parserData)
|
||||
for oneBodyLink in bodyLinks {
|
||||
if linkMightBeFeed(oneBodyLink), let normalizedURL = oneBodyLink.urlString?.normalizedURL {
|
||||
orderFound = orderFound + 1
|
||||
let oneFeedSpecifier = FeedSpecifier(title: oneBodyLink.text, urlString: normalizedURL, source: .HTMLLink, orderFound: orderFound)
|
||||
addFeedSpecifier(oneFeedSpecifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension HTMLFeedFinder {
|
||||
|
||||
func addFeedSpecifier(_ feedSpecifier: FeedSpecifier) {
|
||||
// If there’s an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesn’t, use that non-nil title. Use the better source.
|
||||
|
||||
if let existingFeedSpecifier = feedSpecifiersDictionary[feedSpecifier.urlString] {
|
||||
let mergedFeedSpecifier = existingFeedSpecifier.feedSpecifierByMerging(feedSpecifier)
|
||||
feedSpecifiersDictionary[feedSpecifier.urlString] = mergedFeedSpecifier
|
||||
}
|
||||
else {
|
||||
feedSpecifiersDictionary[feedSpecifier.urlString] = feedSpecifier
|
||||
}
|
||||
}
|
||||
|
||||
func urlStringMightBeFeed(_ urlString: String) -> Bool {
|
||||
let massagedURLString = urlString.replacingOccurrences(of: "buzzfeed", with: "_")
|
||||
|
||||
for oneMatch in feedURLWordsToMatch {
|
||||
let range = (massagedURLString as NSString).range(of: oneMatch, options: .caseInsensitive)
|
||||
if range.length > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func linkMightBeFeed(_ link: HTMLLink) -> Bool {
|
||||
if let linkURLString = link.urlString, urlStringMightBeFeed(linkURLString) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
149
Modules/Account/Sources/Account/FeedMetadata.swift
Normal file
149
Modules/Account/Sources/Account/FeedMetadata.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// FeedMetadata.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 3/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import Articles
|
||||
|
||||
protocol FeedMetadataDelegate: AnyObject {
|
||||
func valueDidChange(_ feedMetadata: FeedMetadata, key: FeedMetadata.CodingKeys)
|
||||
}
|
||||
|
||||
final class FeedMetadata: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case feedID
|
||||
case homePageURL
|
||||
case iconURL
|
||||
case faviconURL
|
||||
case editedName
|
||||
case authors
|
||||
case contentHash
|
||||
case isNotifyAboutNewArticles
|
||||
case isArticleExtractorAlwaysOn
|
||||
case conditionalGetInfo
|
||||
case cacheControlInfo
|
||||
case externalID = "subscriptionID"
|
||||
case folderRelationship
|
||||
}
|
||||
|
||||
var feedID: String {
|
||||
didSet {
|
||||
if feedID != oldValue {
|
||||
valueDidChange(.feedID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var homePageURL: String? {
|
||||
didSet {
|
||||
if homePageURL != oldValue {
|
||||
valueDidChange(.homePageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var iconURL: String? {
|
||||
didSet {
|
||||
if iconURL != oldValue {
|
||||
valueDidChange(.iconURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var faviconURL: String? {
|
||||
didSet {
|
||||
if faviconURL != oldValue {
|
||||
valueDidChange(.faviconURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var editedName: String? {
|
||||
didSet {
|
||||
if editedName != oldValue {
|
||||
valueDidChange(.editedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentHash: String? {
|
||||
didSet {
|
||||
if contentHash != oldValue {
|
||||
valueDidChange(.contentHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isNotifyAboutNewArticles: Bool? {
|
||||
didSet {
|
||||
if isNotifyAboutNewArticles != oldValue {
|
||||
valueDidChange(.isNotifyAboutNewArticles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isArticleExtractorAlwaysOn: Bool? {
|
||||
didSet {
|
||||
if isArticleExtractorAlwaysOn != oldValue {
|
||||
valueDidChange(.isArticleExtractorAlwaysOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authors: [Author]? {
|
||||
didSet {
|
||||
if authors != oldValue {
|
||||
valueDidChange(.authors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var conditionalGetInfo: HTTPConditionalGetInfo? {
|
||||
didSet {
|
||||
if conditionalGetInfo != oldValue {
|
||||
valueDidChange(.conditionalGetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cacheControlInfo: CacheControlInfo? {
|
||||
didSet {
|
||||
if cacheControlInfo != oldValue {
|
||||
valueDidChange(.cacheControlInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var externalID: String? {
|
||||
didSet {
|
||||
if externalID != oldValue {
|
||||
valueDidChange(.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Name: Sync Service Relationship ID
|
||||
var folderRelationship: [String: String]? {
|
||||
didSet {
|
||||
if folderRelationship != oldValue {
|
||||
valueDidChange(.folderRelationship)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: FeedMetadataDelegate?
|
||||
|
||||
init(feedID: String) {
|
||||
self.feedID = feedID
|
||||
}
|
||||
|
||||
func valueDidChange(_ key: CodingKeys) {
|
||||
delegate?.valueDidChange(self, key: key)
|
||||
}
|
||||
}
|
||||
84
Modules/Account/Sources/Account/FeedMetadataFile.swift
Normal file
84
Modules/Account/Sources/Account/FeedMetadataFile.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// FeedMetadataFile.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 9/13/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
|
||||
final class FeedMetadataFile {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "FeedMetadataFile")
|
||||
|
||||
private let fileURL: URL
|
||||
private let account: Account
|
||||
|
||||
private var isDirty = false {
|
||||
didSet {
|
||||
queueSaveToDiskIfNeeded()
|
||||
}
|
||||
}
|
||||
private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5)
|
||||
|
||||
init(filename: String, account: Account) {
|
||||
self.fileURL = URL(fileURLWithPath: filename)
|
||||
self.account = account
|
||||
}
|
||||
|
||||
func markAsDirty() {
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
func load() {
|
||||
if let fileData = try? Data(contentsOf: fileURL) {
|
||||
let decoder = PropertyListDecoder()
|
||||
account.feedMetadata = (try? decoder.decode(Account.FeedMetadataDictionary.self, from: fileData)) ?? Account.FeedMetadataDictionary()
|
||||
}
|
||||
for value in account.feedMetadata.values {
|
||||
value.delegate = account
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard !account.isDeleted else { return }
|
||||
|
||||
let feedMetadata = metadataForOnlySubscribedToFeeds()
|
||||
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
|
||||
do {
|
||||
let data = try encoder.encode(feedMetadata)
|
||||
try data.write(to: fileURL)
|
||||
} catch let error as NSError {
|
||||
os_log(.error, log: log, "Save to disk failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension FeedMetadataFile {
|
||||
|
||||
func queueSaveToDiskIfNeeded() {
|
||||
saveQueue.add(self, #selector(saveToDiskIfNeeded))
|
||||
}
|
||||
|
||||
@objc func saveToDiskIfNeeded() {
|
||||
if isDirty {
|
||||
isDirty = false
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
private func metadataForOnlySubscribedToFeeds() -> Account.FeedMetadataDictionary {
|
||||
let feedIDs = account.idToFeedDictionary.keys
|
||||
return account.feedMetadata.filter { (feedID: String, metadata: FeedMetadata) -> Bool in
|
||||
return feedIDs.contains(metadata.feedID)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
737
Modules/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift
Normal file
737
Modules/Account/Sources/Account/Feedbin/FeedbinAPICaller.swift
Normal file
@@ -0,0 +1,737 @@
|
||||
//
|
||||
// FeedbinAPICaller.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/2/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
// Feedbin currently has a maximum of 250 requests per second. If you begin to receive
|
||||
// HTTP Response Codes of 403, you have exceeded this limit. Wait 5 minutes and your
|
||||
// IP address will become unblocked and you can use the service again.
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
enum CreateSubscriptionResult {
|
||||
case created(FeedbinSubscription)
|
||||
case multipleChoice([FeedbinSubscriptionChoice])
|
||||
case alreadySubscribed
|
||||
case notFound
|
||||
}
|
||||
|
||||
final class FeedbinAPICaller: NSObject {
|
||||
|
||||
struct ConditionalGetKeys {
|
||||
static let subscriptions = "subscriptions"
|
||||
static let tags = "tags"
|
||||
static let taggings = "taggings"
|
||||
static let unreadEntries = "unreadEntries"
|
||||
static let starredEntries = "starredEntries"
|
||||
}
|
||||
|
||||
private let feedbinBaseURL = URL(string: "https://api.feedbin.com/v2/")!
|
||||
private var transport: Transport!
|
||||
private var suspended = false
|
||||
private var lastBackdateStartTime: Date?
|
||||
|
||||
var credentials: Credentials?
|
||||
weak var accountMetadata: AccountMetadata?
|
||||
|
||||
init(transport: Transport) {
|
||||
super.init()
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
/// Cancels all pending requests rejects any that come in later
|
||||
func suspend() {
|
||||
transport.cancelAll()
|
||||
suspended = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
suspended = false
|
||||
}
|
||||
|
||||
func validateCredentials(completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("authentication.json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
|
||||
transport.send(request: request) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(self.credentials))
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
if status == 401 {
|
||||
completion(.success(nil))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
default:
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func importOPML(opmlData: Data, completion: @escaping (Result<FeedbinImportResult, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("imports.json")
|
||||
var request = URLRequest(url: callURL, credentials: credentials)
|
||||
request.addValue("text/xml; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: opmlData) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (_, data)):
|
||||
|
||||
guard let resultData = data else {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try JSONDecoder().decode(FeedbinImportResult.self, from: resultData)
|
||||
completion(.success(result))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveOPMLImportResult(importID: Int, completion: @escaping (Result<FeedbinImportResult?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("imports/\(importID).json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: FeedbinImportResult.self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (_, importResult)):
|
||||
completion(.success(importResult))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveTags(completion: @escaping (Result<[FeedbinTag]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("tags.json")
|
||||
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.tags]
|
||||
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinTag].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, tags)):
|
||||
self.storeConditionalGet(key: ConditionalGetKeys.tags, headers: response.allHeaderFields)
|
||||
completion(.success(tags))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func renameTag(oldName: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("tags.json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
let payload = FeedbinRenameTag(oldName: oldName, newName: newName)
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: payload) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveSubscriptions(completion: @escaping (Result<[FeedbinSubscription]?, Error>) -> Void) {
|
||||
|
||||
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)!
|
||||
callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")]
|
||||
|
||||
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.subscriptions]
|
||||
let request = URLRequest(url: callComponents.url!, credentials: credentials, conditionalGet: conditionalGet)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinSubscription].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, subscriptions)):
|
||||
self.storeConditionalGet(key: ConditionalGetKeys.subscriptions, headers: response.allHeaderFields)
|
||||
completion(.success(subscriptions))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createSubscription(url: String, completion: @escaping (Result<CreateSubscriptionResult, Error>) -> Void) {
|
||||
|
||||
var callComponents = URLComponents(url: feedbinBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)!
|
||||
callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")]
|
||||
|
||||
var request = URLRequest(url: callComponents.url!, credentials: credentials)
|
||||
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
let payload: Data
|
||||
do {
|
||||
payload = try JSONEncoder().encode(FeedbinCreateSubscription(feedURL: url))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: payload) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, data)):
|
||||
|
||||
switch response.forcedStatusCode {
|
||||
case 201:
|
||||
guard let subData = data else {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
do {
|
||||
let subscription = try JSONDecoder().decode(FeedbinSubscription.self, from: subData)
|
||||
completion(.success(.created(subscription)))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case 300:
|
||||
guard let subData = data else {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
do {
|
||||
let subscriptions = try JSONDecoder().decode([FeedbinSubscriptionChoice].self, from: subData)
|
||||
completion(.success(.multipleChoice(subscriptions)))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
case 302:
|
||||
completion(.success(.alreadySubscribed))
|
||||
default:
|
||||
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
|
||||
switch error {
|
||||
case TransportError.httpError(let status):
|
||||
switch status {
|
||||
case 401:
|
||||
// I don't know why we get 401's here. This looks like a Feedbin bug, but it only happens
|
||||
// when you are already subscribed to the feed.
|
||||
completion(.success(.alreadySubscribed))
|
||||
case 404:
|
||||
completion(.success(.notFound))
|
||||
default:
|
||||
completion(.failure(error))
|
||||
}
|
||||
default:
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions/\(subscriptionID)/update.json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
let payload = FeedbinUpdateSubscription(title: newName)
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload: payload) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteSubscription(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("subscriptions/\(subscriptionID).json")
|
||||
let request = URLRequest(url: callURL, credentials: credentials)
|
||||
transport.send(request: request, method: HTTPMethod.delete) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveTaggings(completion: @escaping (Result<[FeedbinTagging]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("taggings.json")
|
||||
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.taggings]
|
||||
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinTagging].self) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, taggings)):
|
||||
self.storeConditionalGet(key: ConditionalGetKeys.taggings, headers: response.allHeaderFields)
|
||||
completion(.success(taggings))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createTagging(feedID: Int, name: String, completion: @escaping (Result<Int, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("taggings.json")
|
||||
var request = URLRequest(url: callURL, credentials: credentials)
|
||||
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
let payload: Data
|
||||
do {
|
||||
payload = try JSONEncoder().encode(FeedbinCreateTagging(feedID: feedID, name: name))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
transport.send(request: request, method: HTTPMethod.post, payload:payload) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, _)):
|
||||
if let taggingLocation = response.valueForHTTPHeaderField(HTTPResponseHeader.location),
|
||||
let lowerBound = taggingLocation.range(of: "v2/taggings/")?.upperBound,
|
||||
let upperBound = taggingLocation.range(of: ".json")?.lowerBound,
|
||||
let taggingID = Int(taggingLocation[lowerBound..<upperBound]) {
|
||||
completion(.success(taggingID))
|
||||
} else {
|
||||
completion(.failure(TransportError.noData))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteTagging(taggingID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("taggings/\(taggingID).json")
|
||||
var request = URLRequest(url: callURL, credentials: credentials)
|
||||
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
transport.send(request: request, method: HTTPMethod.delete) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([FeedbinEntry]?), Error>) -> Void) {
|
||||
|
||||
guard !articleIDs.isEmpty else {
|
||||
completion(.success(([FeedbinEntry]())))
|
||||
return
|
||||
}
|
||||
|
||||
let concatIDs = articleIDs.reduce("") { param, articleID in return param + ",\(articleID)" }
|
||||
let paramIDs = String(concatIDs.dropFirst())
|
||||
|
||||
let url = feedbinBaseURL
|
||||
.appendingPathComponent("entries.json")
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "ids", value: paramIDs),
|
||||
URLQueryItem(name: "mode", value: "extended")
|
||||
])
|
||||
let request = URLRequest(url: url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (_, entries)):
|
||||
completion(.success((entries)))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveEntries(feedID: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) {
|
||||
|
||||
let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
let sinceString = FeedbinDate.formatter.string(from: since)
|
||||
|
||||
let url = feedbinBaseURL
|
||||
.appendingPathComponent("feeds/\(feedID)/entries.json")
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "since", value: sinceString),
|
||||
URLQueryItem(name: "per_page", value: "100"),
|
||||
URLQueryItem(name: "mode", value: "extended")
|
||||
])
|
||||
let request = URLRequest(url: url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, entries)):
|
||||
|
||||
let pagingInfo = HTTPLinkPagingInfo(urlResponse: response)
|
||||
completion(.success((entries, pagingInfo.nextPage)))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveEntries(completion: @escaping (Result<([FeedbinEntry]?, String?, Date?, Int?), Error>) -> Void) {
|
||||
|
||||
// If this is an initial sync, go and grab the previous 3 months of entries. If not, use the last
|
||||
// article fetch to only get the articles **published** since the last article fetch.
|
||||
//
|
||||
// We do a backdate fetch every launch or every 24 hours. This will help with
|
||||
// getting **updated** articles that normally wouldn't be found with a regular fetch.
|
||||
// https://github.com/Ranchero-Software/NetNewsWire/issues/2549#issuecomment-722341356
|
||||
let since: Date = {
|
||||
if let lastArticleFetch = accountMetadata?.lastArticleFetchStartTime {
|
||||
if let lastBackdateStartTime = lastBackdateStartTime {
|
||||
if lastBackdateStartTime.byAdding(days: 1) < lastArticleFetch {
|
||||
self.lastBackdateStartTime = lastArticleFetch
|
||||
return lastArticleFetch.bySubtracting(days: 1)
|
||||
} else {
|
||||
return lastArticleFetch
|
||||
}
|
||||
} else {
|
||||
self.lastBackdateStartTime = lastArticleFetch
|
||||
return lastArticleFetch.bySubtracting(days: 1)
|
||||
}
|
||||
} else {
|
||||
return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
}
|
||||
}()
|
||||
|
||||
let sinceString = FeedbinDate.formatter.string(from: since)
|
||||
let url = feedbinBaseURL
|
||||
.appendingPathComponent("entries.json")
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "since", value: sinceString),
|
||||
URLQueryItem(name: "per_page", value: "100"),
|
||||
URLQueryItem(name: "mode", value: "extended")
|
||||
])
|
||||
let request = URLRequest(url: url!, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, entries)):
|
||||
|
||||
let dateInfo = HTTPDateInfo(urlResponse: response)
|
||||
|
||||
let pagingInfo = HTTPLinkPagingInfo(urlResponse: response)
|
||||
let lastPageNumber = self.extractPageNumber(link: pagingInfo.lastPage)
|
||||
completion(.success((entries, pagingInfo.nextPage, dateInfo?.date, lastPageNumber)))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveEntries(page: String, completion: @escaping (Result<([FeedbinEntry]?, String?), Error>) -> Void) {
|
||||
|
||||
guard let url = URL(string: page) else {
|
||||
completion(.success((nil, nil)))
|
||||
return
|
||||
}
|
||||
|
||||
let request = URLRequest(url: url, credentials: credentials)
|
||||
|
||||
transport.send(request: request, resultType: [FeedbinEntry].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, entries)):
|
||||
|
||||
let pagingInfo = HTTPLinkPagingInfo(urlResponse: response)
|
||||
completion(.success((entries, pagingInfo.nextPage)))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func retrieveUnreadEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("unread_entries.json")
|
||||
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.unreadEntries]
|
||||
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
|
||||
|
||||
transport.send(request: request, resultType: [Int].self) { result in
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, unreadEntries)):
|
||||
self.storeConditionalGet(key: ConditionalGetKeys.unreadEntries, headers: response.allHeaderFields)
|
||||
completion(.success(unreadEntries))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveStarredEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) {
|
||||
|
||||
let callURL = feedbinBaseURL.appendingPathComponent("starred_entries.json")
|
||||
let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.starredEntries]
|
||||
let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet)
|
||||
|
||||
transport.send(request: request, resultType: [Int].self) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let (response, starredEntries)):
|
||||
self.storeConditionalGet(key: ConditionalGetKeys.starredEntries, headers: response.allHeaderFields)
|
||||
completion(.success(starredEntries))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) { result in
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
extension FeedbinAPICaller {
|
||||
|
||||
func storeConditionalGet(key: String, headers: [AnyHashable : Any]) {
|
||||
if var conditionalGet = accountMetadata?.conditionalGetInfo {
|
||||
conditionalGet[key] = HTTPConditionalGetInfo(headers: headers)
|
||||
accountMetadata?.conditionalGetInfo = conditionalGet
|
||||
}
|
||||
}
|
||||
|
||||
func extractPageNumber(link: String?) -> Int? {
|
||||
|
||||
guard let link = link else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let lowerBound = link.range(of: "page=")?.upperBound {
|
||||
let partialLink = link[lowerBound..<link.endIndex]
|
||||
if let upperBound = partialLink.firstIndex(of: "&") {
|
||||
return Int(partialLink[partialLink.startIndex..<upperBound])
|
||||
}
|
||||
if let upperBound = partialLink.firstIndex(of: ">") {
|
||||
return Int(partialLink[partialLink.startIndex..<upperBound])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
1439
Modules/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift
Normal file
1439
Modules/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift
Normal file
File diff suppressed because it is too large
Load Diff
21
Modules/Account/Sources/Account/Feedbin/FeedbinDate.swift
Normal file
21
Modules/Account/Sources/Account/Feedbin/FeedbinDate.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// FeedbinDate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedbinDate {
|
||||
|
||||
public static var formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.timeZone = TimeZone(abbreviation: "GMT")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
}
|
||||
85
Modules/Account/Sources/Account/Feedbin/FeedbinEntry.swift
Normal file
85
Modules/Account/Sources/Account/Feedbin/FeedbinEntry.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// FeedbinArticle.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 12/11/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import RSCore
|
||||
|
||||
final class FeedbinEntry: Decodable {
|
||||
|
||||
let articleID: Int
|
||||
let feedID: Int
|
||||
let title: String?
|
||||
let url: String?
|
||||
let authorName: String?
|
||||
let contentHTML: String?
|
||||
let summary: String?
|
||||
let datePublished: String?
|
||||
let dateArrived: String?
|
||||
let jsonFeed: FeedbinEntryJSONFeed?
|
||||
|
||||
// Feedbin dates can't be decoded by the JSONDecoding 8601 decoding strategy. Feedbin
|
||||
// requires a very specific date formatter to work and even then it fails occasionally.
|
||||
// Rather than loose all the entries we only lose the one date by decoding as a string
|
||||
// and letting the one date fail when parsed.
|
||||
lazy var parsedDatePublished: Date? = {
|
||||
if let datePublished = datePublished {
|
||||
return DateParser.date(string: datePublished)
|
||||
}
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case articleID = "id"
|
||||
case feedID = "feed_id"
|
||||
case title = "title"
|
||||
case url = "url"
|
||||
case authorName = "author"
|
||||
case contentHTML = "content"
|
||||
case summary = "summary"
|
||||
case datePublished = "published"
|
||||
case dateArrived = "created_at"
|
||||
case jsonFeed = "json_feed"
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbinEntryJSONFeed: Decodable {
|
||||
let jsonFeedAuthor: FeedbinEntryJSONFeedAuthor?
|
||||
let jsonFeedExternalURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jsonFeedAuthor = "author"
|
||||
case jsonFeedExternalURL = "external_url"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
do {
|
||||
jsonFeedAuthor = try container.decode(FeedbinEntryJSONFeedAuthor.self, forKey: .jsonFeedAuthor)
|
||||
} catch {
|
||||
jsonFeedAuthor = nil
|
||||
}
|
||||
do {
|
||||
jsonFeedExternalURL = try container.decode(String.self, forKey: .jsonFeedExternalURL)
|
||||
} catch {
|
||||
jsonFeedExternalURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinEntryJSONFeedAuthor: Decodable {
|
||||
let url: String?
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url = "url"
|
||||
case avatarURL = "avatar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// FeedbinImportResult.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/17/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedbinImportResult: Codable {
|
||||
|
||||
let importResultID: Int
|
||||
let complete: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case importResultID = "id"
|
||||
case complete
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// FeedbinFeed.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 12/10/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Parser
|
||||
|
||||
struct FeedbinSubscription: Hashable, Codable {
|
||||
|
||||
let subscriptionID: Int
|
||||
let feedID: Int
|
||||
let name: String?
|
||||
let url: String
|
||||
let homePageURL: String?
|
||||
let jsonFeed: FeedbinSubscriptionJSONFeed?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case subscriptionID = "id"
|
||||
case feedID = "feed_id"
|
||||
case name = "title"
|
||||
case url = "feed_url"
|
||||
case homePageURL = "site_url"
|
||||
case jsonFeed = "json_feed"
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(subscriptionID)
|
||||
}
|
||||
|
||||
static func == (lhs: FeedbinSubscription, rhs: FeedbinSubscription) -> Bool {
|
||||
return lhs.subscriptionID == rhs.subscriptionID
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinSubscriptionJSONFeed: Codable {
|
||||
let favicon: String?
|
||||
let icon: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case favicon = "favicon"
|
||||
case icon = "icon"
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbinCreateSubscription: Codable {
|
||||
let feedURL: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case feedURL = "feed_url"
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbinUpdateSubscription: Codable {
|
||||
let title: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbinSubscriptionChoice: Codable {
|
||||
|
||||
let name: String?
|
||||
let url: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name = "title"
|
||||
case url = "feed_url"
|
||||
}
|
||||
|
||||
}
|
||||
43
Modules/Account/Sources/Account/Feedbin/FeedbinTag.swift
Normal file
43
Modules/Account/Sources/Account/Feedbin/FeedbinTag.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// FeedbinTag.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 5/5/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedbinTag: Codable {
|
||||
|
||||
let tagID: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case tagID = "id"
|
||||
case name = "name"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinRenameTag: Codable {
|
||||
|
||||
let oldName: String
|
||||
let newName: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case oldName = "old_name"
|
||||
case newName = "new_name"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinDeleteTag: Codable {
|
||||
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
}
|
||||
|
||||
}
|
||||
35
Modules/Account/Sources/Account/Feedbin/FeedbinTagging.swift
Normal file
35
Modules/Account/Sources/Account/Feedbin/FeedbinTagging.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// FeedbinTagging.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 10/14/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedbinTagging: Codable {
|
||||
|
||||
let taggingID: Int
|
||||
let feedID: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case taggingID = "id"
|
||||
case feedID = "feed_id"
|
||||
case name = "name"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FeedbinCreateTagging: Codable {
|
||||
|
||||
let feedID: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case feedID = "feed_id"
|
||||
case name = "name"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
955
Modules/Account/Sources/Account/Feedly/FeedlyAPICaller.swift
Normal file
955
Modules/Account/Sources/Account/Feedly/FeedlyAPICaller.swift
Normal file
@@ -0,0 +1,955 @@
|
||||
//
|
||||
// FeedlyAPICaller.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 13/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
protocol FeedlyAPICallerDelegate: AnyObject {
|
||||
/// Implemented by the `FeedlyAccountDelegate` reauthorize the client with a fresh OAuth token so the client can retry the unauthorized request.
|
||||
/// Pass `true` to the completion handler if the failing request should be retried with a fresh token or `false` if the unauthorized request should complete with the original failure error.
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ())
|
||||
}
|
||||
|
||||
final class FeedlyAPICaller {
|
||||
|
||||
enum API {
|
||||
case sandbox
|
||||
case cloud
|
||||
|
||||
var baseUrlComponents: URLComponents {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
switch self{
|
||||
case .sandbox:
|
||||
// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||
components.host = "sandbox7.feedly.com"
|
||||
case .cloud:
|
||||
// https://developer.feedly.com/cloud/
|
||||
components.host = "cloud.feedly.com"
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
var oauthAuthorizationClient: OAuthAuthorizationClient {
|
||||
switch self {
|
||||
case .sandbox:
|
||||
return .feedlySandboxClient
|
||||
case .cloud:
|
||||
return .feedlyCloudClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let transport: Transport
|
||||
private let baseUrlComponents: URLComponents
|
||||
private let uriComponentAllowed: CharacterSet
|
||||
|
||||
init(transport: Transport, api: API) {
|
||||
self.transport = transport
|
||||
self.baseUrlComponents = api.baseUrlComponents
|
||||
|
||||
var urlHostAllowed = CharacterSet.urlHostAllowed
|
||||
urlHostAllowed.remove("+")
|
||||
uriComponentAllowed = urlHostAllowed
|
||||
}
|
||||
|
||||
weak var delegate: FeedlyAPICallerDelegate?
|
||||
|
||||
var credentials: Credentials?
|
||||
|
||||
var server: String? {
|
||||
return baseUrlComponents.host
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
transport.cancelAll()
|
||||
}
|
||||
|
||||
private var isSuspended = false
|
||||
|
||||
/// Cancels all pending requests rejects any that come in later
|
||||
func suspend() {
|
||||
transport.cancelAll()
|
||||
isSuspended = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
isSuspended = false
|
||||
}
|
||||
|
||||
func send<R: Decodable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||
transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { [weak self] result in
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(result)
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case TransportError.httpError(let statusCode) where statusCode == 401:
|
||||
|
||||
assert(self == nil ? true : self?.delegate != nil, "Check the delegate is set to \(FeedlyAccountDelegate.self).")
|
||||
|
||||
guard let self = self, let delegate = self.delegate else {
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
/// Capture the credentials before the reauthorization to check for a change.
|
||||
let credentialsBefore = self.credentials
|
||||
|
||||
delegate.reauthorizeFeedlyAPICaller(self) { [weak self] isReauthorizedAndShouldRetry in
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
guard isReauthorizedAndShouldRetry, let self = self else {
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for a change. Not only would it help debugging, but it'll also catch an infinitely recursive attempt to refresh.
|
||||
guard let accessToken = self.credentials?.secret, accessToken != credentialsBefore?.secret else {
|
||||
assertionFailure("Could not update the request with a new OAuth token. Did \(String(describing: self.delegate)) set them on \(self)?")
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
var reauthorizedRequest = request
|
||||
reauthorizedRequest.setValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
self.send(request: reauthorizedRequest, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding, completion: completion)
|
||||
}
|
||||
default:
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importOpml(_ opmlData: Data, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.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
|
||||
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
if httpResponse.statusCode == 200 {
|
||||
completion(.success(()))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createCollection(named label: String, completion: @escaping (Result<FeedlyCollection, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.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.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
do {
|
||||
struct CreateCollectionBody: Encodable {
|
||||
var label: String
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(CreateCollectionBody(label: label))
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, collections)):
|
||||
if httpResponse.statusCode == 200, let collection = collections?.first {
|
||||
completion(.success(collection))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameCollection(with id: String, to name: String, completion: @escaping (Result<FeedlyCollection, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.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.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
do {
|
||||
struct RenameCollectionBody: Encodable {
|
||||
var id: String
|
||||
var label: String
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(RenameCollectionBody(id: id, label: name))
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, collections)):
|
||||
if httpResponse.statusCode == 200, let collection = collections?.first {
|
||||
completion(.success(collection))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeForURLPath(_ pathComponent: String) -> String? {
|
||||
return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
|
||||
}
|
||||
|
||||
func deleteCollection(with id: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
guard let encodedId = encodeForURLPath(id) else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(id)))
|
||||
}
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedId)"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
send(request: request, resultType: Optional<FeedlyCollection>.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
if httpResponse.statusCode == 200 {
|
||||
completion(.success(()))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(_ feedId: String, fromCollectionWith collectionId: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
guard let encodedCollectionId = encodeForURLPath(collectionId) else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(collectionId)))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/.mdelete"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
do {
|
||||
struct RemovableFeed: Encodable {
|
||||
let id: String
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode([RemovableFeed(id: feedId)])
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// `resultType` is optional because the Feedly API has gone from returning an array of removed feeds to returning `null`.
|
||||
// https://developer.feedly.com/v3/collections/#remove-multiple-feeds-from-a-personal-collection
|
||||
send(request: request, resultType: Optional<[FeedlyFeed]>.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success((let httpResponse, _)):
|
||||
if httpResponse.statusCode == 200 {
|
||||
completion(.success(()))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String? = nil, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
guard let encodedId = encodeForURLPath(collectionId) else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(collectionId)))
|
||||
}
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedId)/feeds"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
do {
|
||||
struct AddFeedBody: Encodable {
|
||||
var id: String
|
||||
var title: String?
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(AddFeedBody(id: feedId.id, title: title))
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success((_, let collectionFeeds)):
|
||||
if let feeds = collectionFeeds {
|
||||
completion(.success(feeds))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
||||
|
||||
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest {
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/auth"
|
||||
components.queryItems = request.queryItems
|
||||
|
||||
guard let url = components.url else {
|
||||
assert(components.scheme != nil)
|
||||
assert(components.host != nil)
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
typealias AccessTokenResponse = FeedlyOAuthAccessTokenResponse
|
||||
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/token"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
request.httpBody = try encoder.encode(authorizationRequest)
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, tokenResponse)):
|
||||
if let response = tokenResponse {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
|
||||
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/token"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
request.httpBody = try encoder.encode(refreshRequest)
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, tokenResponse)):
|
||||
if let response = tokenResponse {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetCollectionsService {
|
||||
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.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)
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
if let response = collections {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetStreamContentsService {
|
||||
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/streams/contents"
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
if let date = newerThan {
|
||||
let value = String(Int(date.timeIntervalSince1970 * 1000))
|
||||
let queryItem = URLQueryItem(name: "newerThan", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
if let flag = unreadOnly {
|
||||
let value = flag ? "true" : "false"
|
||||
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
if let value = continuation, !value.isEmpty {
|
||||
let queryItem = URLQueryItem(name: "continuation", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
queryItems.append(contentsOf: [
|
||||
URLQueryItem(name: "count", value: "1000"),
|
||||
URLQueryItem(name: "streamId", value: resource.id),
|
||||
])
|
||||
|
||||
components.queryItems = queryItems
|
||||
|
||||
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)
|
||||
|
||||
send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
if let response = collections {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetStreamIdsService {
|
||||
|
||||
func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/streams/ids"
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
if let date = newerThan {
|
||||
let value = String(Int(date.timeIntervalSince1970 * 1000))
|
||||
let queryItem = URLQueryItem(name: "newerThan", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
if let flag = unreadOnly {
|
||||
let value = flag ? "true" : "false"
|
||||
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
if let value = continuation, !value.isEmpty {
|
||||
let queryItem = URLQueryItem(name: "continuation", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
queryItems.append(contentsOf: [
|
||||
URLQueryItem(name: "count", value: "10000"),
|
||||
URLQueryItem(name: "streamId", value: resource.id),
|
||||
])
|
||||
|
||||
components.queryItems = queryItems
|
||||
|
||||
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)
|
||||
|
||||
send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
if let response = collections {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetEntriesService {
|
||||
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/entries/.mget"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
do {
|
||||
let body = Array(ids)
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(body)
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, entries)):
|
||||
if let response = entries {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
|
||||
private struct MarkerEntriesBody: Encodable {
|
||||
let type = "entries"
|
||||
var action: String
|
||||
var entryIds: [String]
|
||||
}
|
||||
|
||||
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/markers"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
let articleIdChunks = Array(articleIds).chunked(into: 300)
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var groupError: Error? = nil
|
||||
|
||||
for articleIdChunk in articleIdChunks {
|
||||
|
||||
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)
|
||||
|
||||
do {
|
||||
let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIdChunk))
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(body)
|
||||
request.httpBody = data
|
||||
} catch {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
if httpResponse.statusCode != 200 {
|
||||
groupError = URLError(.cannotDecodeContentData)
|
||||
}
|
||||
case .failure(let error):
|
||||
groupError = error
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if let groupError = groupError {
|
||||
completion(.failure(groupError))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlySearchService {
|
||||
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> ()) {
|
||||
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/search/feeds"
|
||||
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "query", value: query),
|
||||
URLQueryItem(name: "count", value: String(count)),
|
||||
URLQueryItem(name: "locale", value: locale)
|
||||
]
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, searchResponse)):
|
||||
if let response = searchResponse {
|
||||
completion(.success(response))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyLogoutService {
|
||||
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/logout"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
if httpResponse.statusCode == 200 {
|
||||
completion(.success(()))
|
||||
} else {
|
||||
completion(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// FeedlyAccountDelegate+OAuth.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
/// Models the access token response from Feedly.
|
||||
/// https://developer.feedly.com/v3/auth/#exchanging-an-auth-code-for-a-refresh-token-and-an-access-token
|
||||
public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse {
|
||||
/// The ID of the Feedly user.
|
||||
public var id: String
|
||||
|
||||
// Required properties of the OAuth 2.0 Authorization Framework section 4.1.4.
|
||||
public var accessToken: String
|
||||
public var tokenType: String
|
||||
public var expiresIn: Int
|
||||
public var refreshToken: String?
|
||||
public var scope: String
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
|
||||
private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions"
|
||||
|
||||
static func oauthAuthorizationCodeGrantRequest() -> URLRequest {
|
||||
let client = environment.oauthAuthorizationClient
|
||||
let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id,
|
||||
redirectUri: client.redirectUri,
|
||||
scope: oauthAuthorizationGrantScope,
|
||||
state: client.state)
|
||||
let baseURLComponents = environment.baseUrlComponents
|
||||
return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest, baseUrlComponents: baseURLComponents)
|
||||
}
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
let client = environment.oauthAuthorizationClient
|
||||
let request = OAuthAccessTokenRequest(authorizationResponse: response,
|
||||
scope: oauthAuthorizationGrantScope,
|
||||
client: client)
|
||||
let caller = FeedlyAPICaller(transport: transport, api: environment)
|
||||
caller.requestAccessToken(request) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
|
||||
|
||||
let refreshToken: Credentials? = {
|
||||
guard let token = response.refreshToken else {
|
||||
return nil
|
||||
}
|
||||
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
|
||||
}()
|
||||
|
||||
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
|
||||
|
||||
completion(.success(grant))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: OAuthAccessTokenRefreshing {
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
let request = OAuthRefreshAccessTokenRequest(refreshToken: refreshToken, scope: nil, client: client)
|
||||
|
||||
caller.refreshAccessToken(request) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
|
||||
|
||||
let refreshToken: Credentials? = {
|
||||
guard let token = response.refreshToken else {
|
||||
return nil
|
||||
}
|
||||
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
|
||||
}()
|
||||
|
||||
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
|
||||
|
||||
completion(.success(grant))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//
|
||||
// FeedlyAccountDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import RSCore
|
||||
import Parser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
import Secrets
|
||||
|
||||
final class FeedlyAccountDelegate: AccountDelegate {
|
||||
|
||||
/// Feedly has a sandbox API and a production API.
|
||||
/// This property is referred to when clients need to know which environment it should be pointing to.
|
||||
/// The value of this property must match any `OAuthAuthorizationClient` used.
|
||||
/// Currently this is always returning the cloud API, but we are leaving it stubbed out for now.
|
||||
static var environment: FeedlyAPICaller.API {
|
||||
return .cloud
|
||||
}
|
||||
|
||||
// TODO: Kiel, if you decide not to support OPML import you will have to disallow it in the behaviors
|
||||
// See https://developer.feedly.com/v3/opml/
|
||||
var behaviors: AccountBehaviors = [.disallowFeedInRootFolder, .disallowMarkAsUnreadAfterPeriod(31)]
|
||||
|
||||
let isOPMLImportSupported = false
|
||||
|
||||
var isOPMLImportInProgress = false
|
||||
|
||||
var server: String? {
|
||||
return caller.server
|
||||
}
|
||||
|
||||
var credentials: Credentials? {
|
||||
didSet {
|
||||
#if DEBUG
|
||||
// https://developer.feedly.com/v3/developer/
|
||||
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
|
||||
caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
caller.credentials = credentials
|
||||
}
|
||||
}
|
||||
|
||||
let oauthAuthorizationClient: OAuthAuthorizationClient
|
||||
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
/// Set on `accountDidInitialize` for the purposes of refreshing OAuth tokens when they expire.
|
||||
/// See the implementation for `FeedlyAPICallerDelegate`.
|
||||
private weak var initializedAccount: Account?
|
||||
|
||||
internal let caller: FeedlyAPICaller
|
||||
|
||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
|
||||
private let database: SyncDatabase
|
||||
|
||||
private weak var currentSyncAllOperation: MainThreadOperation?
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
|
||||
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API) {
|
||||
// Many operations have their own operation queues, such as the sync all operation.
|
||||
// Making this a serial queue at this higher level of abstraction means we can ensure,
|
||||
// for example, a `FeedlyRefreshAccessTokenOperation` occurs before a `FeedlySyncAllOperation`,
|
||||
// improving our ability to debug, reason about and predict the behaviour of the code.
|
||||
|
||||
if let transport = transport {
|
||||
self.caller = FeedlyAPICaller(transport: transport, api: api)
|
||||
|
||||
} else {
|
||||
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.timeoutIntervalForRequest = 60.0
|
||||
sessionConfiguration.httpShouldSetCookies = false
|
||||
sessionConfiguration.httpCookieAcceptPolicy = .never
|
||||
sessionConfiguration.httpMaximumConnectionsPerHost = 1
|
||||
sessionConfiguration.httpCookieStorage = nil
|
||||
sessionConfiguration.urlCache = nil
|
||||
|
||||
if let userAgentHeaders = UserAgent.headers() {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
self.caller = FeedlyAPICaller(transport: session, api: api)
|
||||
}
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
self.oauthAuthorizationClient = api.oauthAuthorizationClient
|
||||
|
||||
self.caller.delegate = self
|
||||
}
|
||||
|
||||
// MARK: Account API
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
guard currentSyncAllOperation == nil else {
|
||||
os_log(.debug, log: log, "Ignoring refreshAll: Feedly sync already in progress.")
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
guard let credentials = credentials else {
|
||||
os_log(.debug, log: log, "Ignoring refreshAll: Feedly account has no credentials.")
|
||||
completion(.failure(FeedlyAccountDelegateError.notLoggedIn))
|
||||
return
|
||||
}
|
||||
|
||||
refreshProgress.reset()
|
||||
|
||||
let log = self.log
|
||||
|
||||
let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log)
|
||||
|
||||
syncAllOperation.downloadProgress = refreshProgress
|
||||
|
||||
let date = Date()
|
||||
syncAllOperation.syncCompletionHandler = { [weak self] result in
|
||||
if case .success = result {
|
||||
self?.accountMetadata?.lastArticleFetchStartTime = date
|
||||
self?.accountMetadata?.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow)
|
||||
completion(result)
|
||||
self?.refreshProgress.reset()
|
||||
}
|
||||
|
||||
currentSyncAllOperation = syncAllOperation
|
||||
|
||||
operationQueue.add(syncAllOperation)
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account, completion: ((Result<Void, Error>) -> Void)? = nil) {
|
||||
sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion?(.success(()))
|
||||
case .failure(let error):
|
||||
completion?(.failure(error))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
completion?(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
// Ensure remote articles have the same status as they do locally.
|
||||
let send = FeedlySendArticleStatusesOperation(database: database, service: caller, log: log)
|
||||
send.completionBlock = { operation in
|
||||
// TODO: not call with success if operation was canceled? Not sure.
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
operationQueue.add(send)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// - Parameter account: The account whose articles have a remote status.
|
||||
/// - Parameter completion: Call on the main queue.
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
guard let credentials = credentials else {
|
||||
return completion(.success(()))
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
let ingestUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
|
||||
|
||||
group.enter()
|
||||
ingestUnread.completionBlock = { _ in
|
||||
group.leave()
|
||||
|
||||
}
|
||||
|
||||
let ingestStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
|
||||
|
||||
group.enter()
|
||||
ingestStarred.completionBlock = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
operationQueue.addOperations([ingestUnread, ingestStarred])
|
||||
}
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
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 createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
|
||||
let progress = refreshProgress
|
||||
progress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
caller.createCollection(named: name) { result in
|
||||
progress.completeTask()
|
||||
|
||||
switch result {
|
||||
case .success(let collection):
|
||||
if let folder = account.ensureFolder(with: collection.label) {
|
||||
folder.externalID = collection.id
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
// Is the name empty? Or one of the global resource names?
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToAddFolder(name)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let id = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name)))
|
||||
}
|
||||
}
|
||||
|
||||
let nameBefore = folder.name
|
||||
|
||||
caller.renameCollection(with: id, to: name) { result in
|
||||
switch result {
|
||||
case .success(let collection):
|
||||
folder.name = collection.label
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
folder.name = nameBefore
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
folder.name = name
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let id = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay)))
|
||||
}
|
||||
}
|
||||
|
||||
let progress = refreshProgress
|
||||
progress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
caller.deleteCollection(with: id) { result in
|
||||
progress.completeTask()
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
do {
|
||||
guard let credentials = credentials else {
|
||||
throw FeedlyAccountDelegateError.notLoggedIn
|
||||
}
|
||||
|
||||
let addNewFeed = try FeedlyAddNewFeedOperation(account: account,
|
||||
credentials: credentials,
|
||||
url: url,
|
||||
feedName: name,
|
||||
searchService: caller,
|
||||
addToCollectionService: caller,
|
||||
syncUnreadIdsService: caller,
|
||||
getStreamContentsService: caller,
|
||||
database: database,
|
||||
container: container,
|
||||
progress: refreshProgress,
|
||||
log: log)
|
||||
|
||||
addNewFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
operationQueue.add(addNewFeed)
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let folderCollectionIds = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID }
|
||||
guard let collectionIds = folderCollectionIds, let collectionId = collectionIds.first else {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRenameFeed(feed.nameForDisplay, name)))
|
||||
return
|
||||
}
|
||||
|
||||
let feedId = FeedlyFeedResourceId(id: feed.feedID)
|
||||
let editedNameBefore = feed.editedName
|
||||
|
||||
// Adding an existing feed updates it.
|
||||
// Updating feed name in one folder/collection updates it for all folders/collections.
|
||||
caller.addFeed(with: feedId, title: name, toCollectionWith: collectionId) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
|
||||
case .failure(let error):
|
||||
feed.editedName = editedNameBefore
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
// optimistically set the name
|
||||
feed.editedName = name
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
do {
|
||||
guard let credentials = credentials else {
|
||||
throw FeedlyAccountDelegateError.notLoggedIn
|
||||
}
|
||||
|
||||
let resource = FeedlyFeedResourceId(id: feed.feedID)
|
||||
let addExistingFeed = try FeedlyAddExistingFeedOperation(account: account,
|
||||
credentials: credentials,
|
||||
resource: resource,
|
||||
service: caller,
|
||||
container: container,
|
||||
progress: refreshProgress,
|
||||
log: log,
|
||||
customFeedName: feed.editedName)
|
||||
|
||||
|
||||
addExistingFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
operationQueue.add(addExistingFeed)
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let folder = container as? Folder, let collectionId = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFeed(feed)))
|
||||
}
|
||||
}
|
||||
|
||||
caller.removeFeed(feed.feedID, fromCollectionWith: collectionId) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
folder.addFeed(feed)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let from = from as? Folder, let to = to as? Folder else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder))
|
||||
}
|
||||
}
|
||||
|
||||
addFeed(for: account, with: feed, to: to) { [weak self] addResult in
|
||||
switch addResult {
|
||||
// now that we have added the feed, remove it from the other collection
|
||||
case .success:
|
||||
self?.removeFeed(for: account, with: feed, from: from) { removeResult in
|
||||
switch removeResult {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure:
|
||||
from.addFeed(feed)
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed, from, to)))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
from.addFeed(feed)
|
||||
to.removeFeed(feed)
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// optimistically move the feed, undoing as appropriate to the failure
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
account.addFeed(existingFeed, to: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
|
||||
group.enter()
|
||||
restoreFeed(for: account, feed: feed, container: folder) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.update(articles, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
case .success(let articles):
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
self.database.insertStatuses(syncStatuses) { _ in
|
||||
self.database.selectPendingCount { result in
|
||||
if let count = try? result.get(), count > 100 {
|
||||
self.sendArticleStatus(for: account) { _ in }
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
initializedAccount = account
|
||||
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
let logout = FeedlyLogoutOperation(account: account, service: caller, log: log)
|
||||
// Dispatch on the shared queue because the lifetime of the account delegate is uncertain.
|
||||
MainThreadOperationQueue.shared.add(logout)
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
assertionFailure("An `account` instance should enqueue an \(FeedlyRefreshAccessTokenOperation.self) instead.")
|
||||
completion(.success(credentials))
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
|
||||
/// Suspend all network activity
|
||||
func suspendNetwork() {
|
||||
caller.suspend()
|
||||
operationQueue.cancelAllOperations()
|
||||
}
|
||||
|
||||
/// Suspend the SQLLite databases
|
||||
func suspendDatabase() {
|
||||
database.suspend()
|
||||
}
|
||||
|
||||
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||
func resume() {
|
||||
database.resume()
|
||||
caller.resume()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: FeedlyAPICallerDelegate {
|
||||
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ()) {
|
||||
guard let account = initializedAccount else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
|
||||
/// Captures a failure to refresh a token, assuming that it was refreshed unless told otherwise.
|
||||
final class RefreshAccessTokenOperationDelegate: FeedlyOperationDelegate {
|
||||
|
||||
private(set) var didReauthorize = true
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
didReauthorize = false
|
||||
}
|
||||
}
|
||||
|
||||
let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log)
|
||||
refreshAccessToken.downloadProgress = refreshProgress
|
||||
|
||||
/// This must be strongly referenced by the completionBlock of the `FeedlyRefreshAccessTokenOperation`.
|
||||
let refreshAccessTokenDelegate = RefreshAccessTokenOperationDelegate()
|
||||
refreshAccessToken.delegate = refreshAccessTokenDelegate
|
||||
|
||||
refreshAccessToken.completionBlock = { operation in
|
||||
assert(Thread.isMainThread)
|
||||
completionHandler(refreshAccessTokenDelegate.didReauthorize && !operation.isCanceled)
|
||||
}
|
||||
|
||||
MainThreadOperationQueue.shared.add(refreshAccessToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// FeedlyAccountDelegateError.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FeedlyAccountDelegateError: LocalizedError {
|
||||
case notLoggedIn
|
||||
case unexpectedResourceId(String)
|
||||
case unableToAddFolder(String)
|
||||
case unableToRenameFolder(String, String)
|
||||
case unableToRemoveFolder(String)
|
||||
case unableToMoveFeedBetweenFolders(Feed, Folder, Folder)
|
||||
case addFeedChooseFolder
|
||||
case addFeedInvalidFolder(Folder)
|
||||
case unableToRenameFeed(String, String)
|
||||
case unableToRemoveFeed(Feed)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn:
|
||||
return NSLocalizedString("Please add the Feedly account again. If this problem persists, open Keychain Access and delete all feedly.com entries, then try again.", comment: "Feedly – Credentials not found.")
|
||||
|
||||
case .unexpectedResourceId(let resourceId):
|
||||
let template = NSLocalizedString("Could not encode the identifier “%@”.", comment: "Feedly – Could not encode resource id to send to Feedly.")
|
||||
return String(format: template, resourceId)
|
||||
|
||||
case .unableToAddFolder(let name):
|
||||
let template = NSLocalizedString("Could not create a folder named “%@”.", comment: "Feedly – Could not create a folder/collection.")
|
||||
return String(format: template, name)
|
||||
|
||||
case .unableToRenameFolder(let from, let to):
|
||||
let template = NSLocalizedString("Could not rename “%@” to “%@”.", comment: "Feedly – Could not rename a folder/collection.")
|
||||
return String(format: template, from, to)
|
||||
|
||||
case .unableToRemoveFolder(let name):
|
||||
let template = NSLocalizedString("Could not remove the folder named “%@”.", comment: "Feedly – Could not remove a folder/collection.")
|
||||
return String(format: template, name)
|
||||
|
||||
case .unableToMoveFeedBetweenFolders(let feed, _, let to):
|
||||
let template = NSLocalizedString("Could not move “%@” to “%@”.", comment: "Feedly – Could not move a feed between folders/collections.")
|
||||
return String(format: template, feed.nameForDisplay, to.nameForDisplay)
|
||||
|
||||
case .addFeedChooseFolder:
|
||||
return NSLocalizedString("Please choose a folder to contain the feed.", comment: "Feedly – Feed can only be added to folders.")
|
||||
|
||||
case .addFeedInvalidFolder(let invalidFolder):
|
||||
let template = NSLocalizedString("Feeds cannot be added to the “%@” folder.", comment: "Feedly – Feed can only be added to folders.")
|
||||
return String(format: template, invalidFolder.nameForDisplay)
|
||||
|
||||
case .unableToRenameFeed(let from, let to):
|
||||
let template = NSLocalizedString("Could not rename “%@” to “%@”.", comment: "Feedly – Could not rename a feed.")
|
||||
return String(format: template, from, to)
|
||||
|
||||
case .unableToRemoveFeed(let feed):
|
||||
let template = NSLocalizedString("Could not remove “%@”.", comment: "Feedly – Could not remove a feed.")
|
||||
return String(format: template, feed.nameForDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .notLoggedIn:
|
||||
return nil
|
||||
|
||||
case .unexpectedResourceId:
|
||||
let template = NSLocalizedString("Please contact NetNewsWire support.", comment: "Feedly – Recovery suggestion for not being able to encode a resource id to send to Feedly..")
|
||||
return String(format: template)
|
||||
|
||||
case .unableToAddFolder:
|
||||
return nil
|
||||
|
||||
case .unableToRenameFolder:
|
||||
return nil
|
||||
|
||||
case .unableToRemoveFolder:
|
||||
return nil
|
||||
|
||||
case .unableToMoveFeedBetweenFolders(let feed, let from, let to):
|
||||
let template = NSLocalizedString("“%@” may be in both “%@” and “%@”.", comment: "Feedly – Could not move a feed between folders/collections.")
|
||||
return String(format: template, feed.nameForDisplay, from.nameForDisplay, to.nameForDisplay)
|
||||
|
||||
case .addFeedChooseFolder:
|
||||
return nil
|
||||
|
||||
case .addFeedInvalidFolder:
|
||||
return NSLocalizedString("Please choose a different folder to contain the feed.", comment: "Feedly – Feed can only be added to folders recovery suggestion.")
|
||||
|
||||
case .unableToRemoveFeed:
|
||||
return nil
|
||||
|
||||
case .unableToRenameFeed:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// FeedlyFeedContainerValidator.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 10/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyFeedContainerValidator {
|
||||
var container: Container
|
||||
|
||||
func getValidContainer() throws -> (Folder, String) {
|
||||
guard let folder = container as? Folder else {
|
||||
throw FeedlyAccountDelegateError.addFeedChooseFolder
|
||||
}
|
||||
|
||||
guard let collectionId = folder.externalID else {
|
||||
throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder)
|
||||
}
|
||||
|
||||
return (folder, collectionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// FeedlyResourceProviding.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 11/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyResourceProviding {
|
||||
var resource: FeedlyResourceId { get }
|
||||
}
|
||||
|
||||
extension FeedlyFeedResourceId: FeedlyResourceProviding {
|
||||
|
||||
var resource: FeedlyResourceId {
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
let label: String
|
||||
let 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: Codable {
|
||||
let feeds: [FeedlyFeed]
|
||||
let label: String
|
||||
let id: String
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// FeedlyCollectionParser.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 28/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyCollectionParser {
|
||||
let collection: FeedlyCollection
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
var folderName: String {
|
||||
return rightToLeftTextSantizer.sanitize(collection.label) ?? ""
|
||||
}
|
||||
|
||||
var externalID: String {
|
||||
return collection.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// FeedlyEntry.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyEntry: Decodable {
|
||||
/// the unique, immutable ID for this particular article.
|
||||
let id: String
|
||||
|
||||
/// the article’s title. This string does not contain any HTML markup.
|
||||
let title: String?
|
||||
|
||||
struct Content: Decodable {
|
||||
|
||||
enum Direction: String, Decodable {
|
||||
case leftToRight = "ltr"
|
||||
case rightToLeft = "rtl"
|
||||
}
|
||||
|
||||
let content: String?
|
||||
let 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.
|
||||
let content: Content?
|
||||
|
||||
/// content object the article summary. See the content object above.
|
||||
let summary: Content?
|
||||
|
||||
/// the author’s name
|
||||
let author: String?
|
||||
|
||||
/// the immutable timestamp, in ms, when this article was processed by the feedly Cloud servers.
|
||||
let crawled: Date
|
||||
|
||||
/// the timestamp, in ms, when this article was re-processed and updated by the feedly Cloud servers.
|
||||
let recrawled: 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.
|
||||
let origin: FeedlyOrigin?
|
||||
|
||||
/// Used to help find the URL to visit an article on a web site.
|
||||
/// See https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
let canonical: [FeedlyLink]?
|
||||
|
||||
/// 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.
|
||||
let alternate: [FeedlyLink]?
|
||||
|
||||
/// 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.
|
||||
let 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.
|
||||
let tags: [FeedlyTag]?
|
||||
|
||||
/// 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.
|
||||
let categories: [FeedlyCategory]?
|
||||
|
||||
/// 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.
|
||||
let enclosure: [FeedlyLink]?
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// FeedlyEntryIdentifierProviding.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyEntryIdentifierProviding: AnyObject {
|
||||
var entryIds: Set<String> { get }
|
||||
}
|
||||
|
||||
final class FeedlyEntryIdentifierProvider: FeedlyEntryIdentifierProviding {
|
||||
private(set) var entryIds: Set<String>
|
||||
|
||||
init(entryIds: Set<String> = Set()) {
|
||||
self.entryIds = entryIds
|
||||
}
|
||||
|
||||
func addEntryIds(from provider: FeedlyEntryIdentifierProviding) {
|
||||
entryIds.formUnion(provider.entryIds)
|
||||
}
|
||||
|
||||
func addEntryIds(in articleIds: [String]) {
|
||||
entryIds.formUnion(articleIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// FeedlyEntryParser.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Parser
|
||||
|
||||
struct FeedlyEntryParser {
|
||||
let entry: FeedlyEntry
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
var id: String {
|
||||
return entry.id
|
||||
}
|
||||
|
||||
/// When ingesting articles, the feedURL must match a feed's `feedID` for the article to be reachable between it and its matching feed. It reminds me of a foreign key.
|
||||
var feedUrl: String? {
|
||||
guard let id = entry.origin?.streamId else {
|
||||
// At this point, check Feedly's API isn't glitching or the response has not changed structure.
|
||||
assertionFailure("Entries need to be traceable to a feed or this entry will be dropped.")
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/// Convoluted external URL logic "documented" here:
|
||||
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
var externalUrl: String? {
|
||||
let multidimensionalArrayOfLinks = [entry.canonical, entry.alternate]
|
||||
let withExistingValues = multidimensionalArrayOfLinks.compactMap { $0 }
|
||||
let flattened = withExistingValues.flatMap { $0 }
|
||||
let webPageLinks = flattened.filter { $0.type == nil || $0.type == "text/html" }
|
||||
return webPageLinks.first?.href
|
||||
}
|
||||
|
||||
var title: String? {
|
||||
return rightToLeftTextSantizer.sanitize(entry.title)
|
||||
}
|
||||
|
||||
var contentHMTL: String? {
|
||||
return entry.content?.content ?? entry.summary?.content
|
||||
}
|
||||
|
||||
var contentText: String? {
|
||||
// We could strip HTML from contentHTML?
|
||||
return nil
|
||||
}
|
||||
|
||||
var summary: String? {
|
||||
return rightToLeftTextSantizer.sanitize(entry.summary?.content)
|
||||
}
|
||||
|
||||
var datePublished: Date {
|
||||
return entry.crawled
|
||||
}
|
||||
|
||||
var dateModified: Date? {
|
||||
return entry.recrawled
|
||||
}
|
||||
|
||||
var authors: Set<ParsedAuthor>? {
|
||||
guard let name = entry.author else {
|
||||
return nil
|
||||
}
|
||||
return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
}
|
||||
|
||||
/// While there is not yet a tagging interface, articles can still be searched for by tags.
|
||||
var tags: Set<String>? {
|
||||
guard let labels = entry.tags?.compactMap({ $0.label }), !labels.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return Set(labels)
|
||||
}
|
||||
|
||||
var attachments: Set<ParsedAttachment>? {
|
||||
guard let enclosure = entry.enclosure, !enclosure.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let attachments = enclosure.compactMap { ParsedAttachment(url: $0.href, mimeType: $0.type, title: nil, sizeInBytes: nil, durationInSeconds: nil) }
|
||||
return attachments.isEmpty ? nil : Set(attachments)
|
||||
}
|
||||
|
||||
var parsedItemRepresentation: ParsedItem? {
|
||||
guard let feedUrl = feedUrl else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ParsedItem(syncServiceID: id,
|
||||
uniqueID: id, // This value seems to get ignored or replaced.
|
||||
feedURL: feedUrl,
|
||||
url: nil,
|
||||
externalURL: externalUrl,
|
||||
title: title,
|
||||
language: nil,
|
||||
contentHTML: contentHMTL,
|
||||
contentText: contentText,
|
||||
summary: summary,
|
||||
imageURL: nil,
|
||||
bannerImageURL: nil,
|
||||
datePublished: datePublished,
|
||||
dateModified: dateModified,
|
||||
authors: authors,
|
||||
tags: tags,
|
||||
attachments: attachments)
|
||||
}
|
||||
}
|
||||
@@ -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: Codable {
|
||||
let id: String
|
||||
let title: String?
|
||||
let updated: Date?
|
||||
let website: String?
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// FeedlyFeedParser.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 29/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyFeedParser {
|
||||
let feed: FeedlyFeed
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
var title: String? {
|
||||
return rightToLeftTextSantizer.sanitize(feed.title) ?? ""
|
||||
}
|
||||
|
||||
var feedID: String {
|
||||
return feed.id
|
||||
}
|
||||
|
||||
var url: String {
|
||||
let resource = FeedlyFeedResourceId(id: feed.id)
|
||||
return resource.url
|
||||
}
|
||||
|
||||
var homePageURL: String? {
|
||||
return feed.website
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// FeedlyFeedsSearchResponse.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 1/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyFeedsSearchResponse: Decodable {
|
||||
|
||||
struct Feed: Decodable {
|
||||
let title: String
|
||||
let feedId: String
|
||||
}
|
||||
|
||||
let results: [Feed]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// FeedlyLink.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyLink: Decodable {
|
||||
let href: String
|
||||
|
||||
/// The mime type of the resource located by `href`.
|
||||
/// When `nil`, it's probably a web page?
|
||||
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
let type: String?
|
||||
}
|
||||
@@ -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 {
|
||||
let title: String?
|
||||
let streamId: String?
|
||||
let htmlUrl: String?
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// FeedlyRTLTextSanitizer.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 28/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyRTLTextSanitizer {
|
||||
private let rightToLeftPrefix = "<div style=\"direction:rtl;text-align:right\">"
|
||||
private let rightToLeftSuffix = "</div>"
|
||||
|
||||
func sanitize(_ sourceText: String?) -> String? {
|
||||
guard let source = sourceText, !source.isEmpty else {
|
||||
return sourceText
|
||||
}
|
||||
|
||||
guard source.hasPrefix(rightToLeftPrefix) && source.hasSuffix(rightToLeftSuffix) else {
|
||||
return source
|
||||
}
|
||||
|
||||
let start = source.index(source.startIndex, offsetBy: rightToLeftPrefix.indices.count)
|
||||
let end = source.index(source.endIndex, offsetBy: -rightToLeftSuffix.indices.count)
|
||||
return String(source[start..<end])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// FeedlyResourceId.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The kinds of Resource Ids is documented here: https://developer.feedly.com/cloud/
|
||||
protocol FeedlyResourceId {
|
||||
|
||||
/// The resource Id from Feedly.
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
/// The Feed Resource is documented here: https://developer.feedly.com/cloud/
|
||||
struct FeedlyFeedResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
/// The location of the kind of resource a concrete type represents.
|
||||
/// If the concrete type cannot strip the resource type from the Id, it should just return the Id
|
||||
/// since the Id is a legitimate URL.
|
||||
/// This is basically assuming Feedly prefixes source feed URLs with `feed/`.
|
||||
/// It is not documented as such and could potentially change.
|
||||
/// Feedly does not include the source feed URL as a separate field.
|
||||
/// See https://developer.feedly.com/v3/feeds/#get-the-metadata-about-a-specific-feed
|
||||
var url: String {
|
||||
if let range = id.range(of: "feed/"), range.lowerBound == id.startIndex {
|
||||
var mutant = id
|
||||
mutant.removeSubrange(range)
|
||||
return mutant
|
||||
}
|
||||
|
||||
// It seems values like "something/https://my.blog/posts.xml" is a legit URL.
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyFeedResourceId {
|
||||
init(url: String) {
|
||||
self.id = "feed/\(url)"
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedlyCategoryResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
enum Global {
|
||||
|
||||
static func uncategorized(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/category/global.uncategorized"
|
||||
return FeedlyCategoryResourceId(id: id)
|
||||
}
|
||||
|
||||
/// All articles from all the feeds the user subscribes to.
|
||||
static func all(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/category/global.all"
|
||||
return FeedlyCategoryResourceId(id: id)
|
||||
}
|
||||
|
||||
/// All articles from all the feeds the user loves most.
|
||||
static func mustRead(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/category/global.must"
|
||||
return FeedlyCategoryResourceId(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedlyTagResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
enum Global {
|
||||
|
||||
static func saved(for userId: String) -> FeedlyTagResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/tag/global.saved"
|
||||
return FeedlyTagResourceId(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// FeedlyStream.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyStream: Decodable {
|
||||
let id: String
|
||||
|
||||
/// Of the most recent entry for this stream (regardless of continuation, newerThan, etc).
|
||||
let updated: Date?
|
||||
|
||||
/// the continuation id to pass to the next stream call, for pagination.
|
||||
/// This id guarantees that no entry will be duplicated in a stream (meaning, there is no need to de-duplicate entries returned by this call).
|
||||
/// If this value is not returned, it means the end of the stream has been reached.
|
||||
let continuation: String?
|
||||
let items: [FeedlyEntry]
|
||||
|
||||
var isStreamEnd: Bool {
|
||||
return continuation == nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// FeedlyStreamIds.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyStreamIds: Decodable {
|
||||
let continuation: String?
|
||||
let ids: [String]
|
||||
|
||||
var isStreamEnd: Bool {
|
||||
return continuation == nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// FeedlyTag.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedlyTag: Decodable {
|
||||
let id: String
|
||||
let label: String?
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// OAuthAccountAuthorizationOperation.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Kiel Gillard on 8/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import RSCore
|
||||
|
||||
public protocol OAuthAccountAuthorizationOperationDelegate: AnyObject {
|
||||
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account)
|
||||
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error)
|
||||
}
|
||||
|
||||
public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
case duplicateAccount
|
||||
|
||||
public var errorDescription: String? {
|
||||
return NSLocalizedString("There is already a Feedly account with that username created.", comment: "Duplicate Error")
|
||||
}
|
||||
}
|
||||
@objc public final class OAuthAccountAuthorizationOperation: NSObject, MainThreadOperation, ASWebAuthenticationPresentationContextProviding {
|
||||
|
||||
public var isCanceled: Bool = false {
|
||||
didSet {
|
||||
if isCanceled {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
public var id: Int?
|
||||
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String?
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
public weak var presentationAnchor: ASPresentationAnchor?
|
||||
public weak var delegate: OAuthAccountAuthorizationOperationDelegate?
|
||||
|
||||
private let accountType: AccountType
|
||||
private let oauthClient: OAuthAuthorizationClient
|
||||
private var session: ASWebAuthenticationSession?
|
||||
|
||||
public init(accountType: AccountType) {
|
||||
self.accountType = accountType
|
||||
self.oauthClient = Account.oauthAuthorizationClient(for: accountType)
|
||||
}
|
||||
|
||||
public func run() {
|
||||
assert(presentationAnchor != nil, "\(self) outlived presentation anchor.")
|
||||
|
||||
let request = Account.oauthAuthorizationCodeGrantRequest(for: accountType)
|
||||
|
||||
guard let url = request.url else {
|
||||
return DispatchQueue.main.async {
|
||||
self.didEndAuthentication(url: nil, error: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
guard let redirectUri = URL(string: oauthClient.redirectUri), let scheme = redirectUri.scheme else {
|
||||
assertionFailure("Could not get callback URL scheme from \(oauthClient.redirectUri)")
|
||||
return DispatchQueue.main.async {
|
||||
self.didEndAuthentication(url: nil, error: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.didEndAuthentication(url: url, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationContextProvider = self
|
||||
|
||||
guard session.start() else {
|
||||
|
||||
/// Documentation does not say on why `ASWebAuthenticationSession.start` or `canStart` might return false.
|
||||
/// Perhaps it has something to do with an inter-process communication failure? No browsers installed? No browsers that support web authentication?
|
||||
struct UnableToStartASWebAuthenticationSessionError: LocalizedError {
|
||||
let errorDescription: String? = NSLocalizedString("Unable to start a web authentication session with the default web browser.",
|
||||
comment: "OAuth - error description - unable to authorize because ASWebAuthenticationSession did not start.")
|
||||
let recoverySuggestion: String? = NSLocalizedString("Check your default web browser in System Preferences or change it to Safari and try again.",
|
||||
comment: "OAuth - recovery suggestion - ensure browser selected supports web authentication.")
|
||||
}
|
||||
|
||||
didFinish(UnableToStartASWebAuthenticationSessionError())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
session?.cancel()
|
||||
}
|
||||
|
||||
private func didEndAuthentication(url: URL?, error: Error?) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let url = url else {
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
let response = try OAuthAuthorizationResponse(url: url, client: oauthClient)
|
||||
|
||||
Account.requestOAuthAccessToken(with: response, client: oauthClient, accountType: accountType, completion: didEndRequestingAccessToken(_:))
|
||||
|
||||
} catch is ASWebAuthenticationSessionError {
|
||||
didFinish() // Primarily, cancellation.
|
||||
|
||||
} catch {
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
guard let anchor = presentationAnchor else {
|
||||
fatalError("\(self) has outlived presentation anchor.")
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
private func didEndRequestingAccessToken(_ result: Result<OAuthAuthorizationGrant, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let tokenResponse):
|
||||
saveAccount(for: tokenResponse)
|
||||
case .failure(let error):
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAccount(for grant: OAuthAuthorizationGrant) {
|
||||
guard !AccountManager.shared.duplicateServiceAccount(type: .feedly, username: grant.accessToken.username) else {
|
||||
didFinish(OAuthAccountAuthorizationOperationError.duplicateAccount)
|
||||
return
|
||||
}
|
||||
|
||||
let account = AccountManager.shared.createAccount(type: .feedly)
|
||||
do {
|
||||
|
||||
// Store the refresh token first because it sends this token to the account delegate.
|
||||
if let token = grant.refreshToken {
|
||||
try account.storeCredentials(token)
|
||||
}
|
||||
|
||||
// Now store the access token because we want the account delegate to use it.
|
||||
try account.storeCredentials(grant.accessToken)
|
||||
|
||||
delegate?.oauthAccountAuthorizationOperation(self, didCreate: account)
|
||||
|
||||
didFinish()
|
||||
} catch {
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Managing Operation State
|
||||
|
||||
private func didFinish() {
|
||||
assert(Thread.isMainThread)
|
||||
operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
|
||||
private func didFinish(_ error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error)
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// OAuthAcessTokenRefreshing.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 4/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
/// Models section 6 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-6
|
||||
public struct OAuthRefreshAccessTokenRequest: Encodable {
|
||||
public let grantType = "refresh_token"
|
||||
public var refreshToken: String
|
||||
public var scope: String?
|
||||
|
||||
// Possibly not part of the standard but specific to certain implementations (e.g.: Feedly).
|
||||
public var clientId: String
|
||||
public var clientSecret: String
|
||||
|
||||
public init(refreshToken: String, scope: String?, client: OAuthAuthorizationClient) {
|
||||
self.refreshToken = refreshToken
|
||||
self.scope = scope
|
||||
self.clientId = client.id
|
||||
self.clientSecret = client.secret
|
||||
}
|
||||
}
|
||||
|
||||
/// Conformed to by API callers to provide a consistent interface for `AccountDelegate` types to refresh OAuth Access Tokens. Conformers provide an associated type that models any custom parameters/properties, as well as the standard ones, in the response to a request for an access token.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-6
|
||||
public protocol OAuthAcessTokenRefreshRequesting {
|
||||
associatedtype AccessTokenResponse: OAuthAccessTokenResponse
|
||||
|
||||
/// Access tokens expire. Perform a request for a fresh access token given the long life refresh token received when authorization was granted.
|
||||
/// - Parameter refreshRequest: The refresh token and other information the authorization server requires to grant the client fresh access tokens on the user's behalf.
|
||||
/// - Parameter completion: On success, the access token response appropriate for concrete type's service. Both the access and refresh token should be stored, preferably on the Keychain. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> ())
|
||||
}
|
||||
|
||||
/// Implemented by concrete types to perform the actual request.
|
||||
protocol OAuthAccessTokenRefreshing: AnyObject {
|
||||
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// OAuthAuthorizationClient+NetNewsWire.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 8/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Secrets
|
||||
|
||||
extension OAuthAuthorizationClient {
|
||||
|
||||
static var feedlyCloudClient: OAuthAuthorizationClient {
|
||||
/// Models private NetNewsWire client secrets.
|
||||
/// These placeholders are substituted at build time using a Run Script phase with build settings.
|
||||
/// https://developer.feedly.com/v3/auth/#authenticating-a-user-and-obtaining-an-auth-code
|
||||
return OAuthAuthorizationClient(id: SecretKey.feedlyClientID,
|
||||
redirectUri: "netnewswire://auth/feedly",
|
||||
state: nil,
|
||||
secret: SecretKey.feedlyClientSecret)
|
||||
}
|
||||
|
||||
static var feedlySandboxClient: OAuthAuthorizationClient {
|
||||
/// We use this funky redirect URI because ASWebAuthenticationSession will try to load http://localhost URLs.
|
||||
/// See https://developer.feedly.com/v3/sandbox/ for more information.
|
||||
/// The return value models public sandbox API values found at:
|
||||
/// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||
/// They are due to expire on May 31st 2020.
|
||||
/// Verify the sandbox URL host in the FeedlyAPICaller.API.baseUrlComponents method, too.
|
||||
return OAuthAuthorizationClient(id: "sandbox",
|
||||
redirectUri: "urn:ietf:wg:oauth:2.0:oob",
|
||||
state: nil,
|
||||
secret: "4ZfZ5DvqmJ8vKgMj")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// OAuthAuthorizationCodeGranting.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
/// Client-specific information for requesting an authorization code grant.
|
||||
/// Accounts are responsible for the scope.
|
||||
public struct OAuthAuthorizationClient: Equatable {
|
||||
public var id: String
|
||||
public var redirectUri: String
|
||||
public var state: String?
|
||||
public var secret: String
|
||||
|
||||
public init(id: String, redirectUri: String, state: String?, secret: String) {
|
||||
self.id = id
|
||||
self.redirectUri = redirectUri
|
||||
self.state = state
|
||||
self.secret = secret
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.1 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.1
|
||||
public struct OAuthAuthorizationRequest {
|
||||
public let responseType = "code"
|
||||
public var clientId: String
|
||||
public var redirectUri: String
|
||||
public var scope: String
|
||||
public var state: String?
|
||||
|
||||
public init(clientId: String, redirectUri: String, scope: String, state: String?) {
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
self.scope = scope
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var queryItems: [URLQueryItem] {
|
||||
return [
|
||||
URLQueryItem(name: "response_type", value: responseType),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "scope", value: scope),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectUri),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.2 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
public struct OAuthAuthorizationResponse {
|
||||
public var code: String
|
||||
public var state: String?
|
||||
}
|
||||
|
||||
public extension OAuthAuthorizationResponse {
|
||||
|
||||
init(url: URL, client: OAuthAuthorizationClient) throws {
|
||||
guard let scheme = url.scheme, client.redirectUri.hasPrefix(scheme) else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
guard let queryItems = components.queryItems, !queryItems.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
let code = queryItems.first { $0.name.lowercased() == "code" }
|
||||
guard let codeValue = code?.value, !codeValue.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
|
||||
let state = queryItems.first { $0.name.lowercased() == "state" }
|
||||
let stateValue = state?.value
|
||||
|
||||
self.init(code: codeValue, state: stateValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.2.1 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
public struct OAuthAuthorizationErrorResponse: Error {
|
||||
public var error: OAuthAuthorizationError
|
||||
public var state: String?
|
||||
public var errorDescription: String?
|
||||
|
||||
public var localizedDescription: String {
|
||||
return errorDescription ?? error.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Error values as enumerated in section 4.1.2.1 of the OAuth 2.0 Authorization Framework.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
public enum OAuthAuthorizationError: String {
|
||||
case invalidRequest = "invalid_request"
|
||||
case unauthorizedClient = "unauthorized_client"
|
||||
case accessDenied = "access_denied"
|
||||
case unsupportedResponseType = "unsupported_response_type"
|
||||
case invalidScope = "invalid_scope"
|
||||
case serverError = "server_error"
|
||||
case temporarilyUnavailable = "temporarily_unavailable"
|
||||
}
|
||||
|
||||
/// Models section 4.1.3 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.3
|
||||
public struct OAuthAccessTokenRequest: Encodable {
|
||||
public let grantType = "authorization_code"
|
||||
public var code: String
|
||||
public var redirectUri: String
|
||||
public var state: String?
|
||||
public var clientId: String
|
||||
|
||||
// Possibly not part of the standard but specific to certain implementations (e.g.: Feedly).
|
||||
public var clientSecret: String
|
||||
public var scope: String
|
||||
|
||||
public init(authorizationResponse: OAuthAuthorizationResponse, scope: String, client: OAuthAuthorizationClient) {
|
||||
self.code = authorizationResponse.code
|
||||
self.redirectUri = client.redirectUri
|
||||
self.state = authorizationResponse.state
|
||||
self.clientId = client.id
|
||||
self.clientSecret = client.secret
|
||||
self.scope = scope
|
||||
}
|
||||
}
|
||||
|
||||
/// Models the minimum subset of properties of a response in section 4.1.4 of the OAuth 2.0 Authorization Framework
|
||||
/// Concrete types model other parameters beyond the scope of the OAuth spec.
|
||||
/// For example, Feedly provides the ID of the user who has consented to the grant.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.4
|
||||
public protocol OAuthAccessTokenResponse {
|
||||
var accessToken: String { get }
|
||||
var tokenType: String { get }
|
||||
var expiresIn: Int { get }
|
||||
var refreshToken: String? { get }
|
||||
var scope: String { get }
|
||||
}
|
||||
|
||||
/// The access and refresh tokens from a successful authorization grant.
|
||||
public struct OAuthAuthorizationGrant: Equatable {
|
||||
public var accessToken: Credentials
|
||||
public var refreshToken: Credentials?
|
||||
}
|
||||
|
||||
/// Conformed to by API callers to provide a consistent interface for `AccountDelegate` types to enable OAuth Authorization Grants. Conformers provide an associated type that models any custom parameters/properties, as well as the standard ones, in the response to a request for an access token.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1
|
||||
public protocol OAuthAuthorizationCodeGrantRequesting {
|
||||
associatedtype AccessTokenResponse: OAuthAccessTokenResponse
|
||||
|
||||
/// Provides the URL request that allows users to consent to the client having access to their information. Typically loaded by a web view.
|
||||
/// - Parameter request: The information about the client requesting authorization to be granted access tokens.
|
||||
/// - Parameter baseUrlComponents: The scheme and host of the url except for the path.
|
||||
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest
|
||||
|
||||
|
||||
/// Performs the request for the access token given an authorization code.
|
||||
/// - Parameter authorizationRequest: The authorization code and other information the authorization server requires to grant the client access tokens on the user's behalf.
|
||||
/// - Parameter completion: On success, the access token response appropriate for concrete type's service. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> ())
|
||||
}
|
||||
|
||||
protocol OAuthAuthorizationGranting: AccountDelegate {
|
||||
|
||||
static func oauthAuthorizationCodeGrantRequest() -> URLRequest
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// FeedlyAddExistingFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 27/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
|
||||
class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
var addCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
|
||||
init(account: Account, credentials: Credentials, resource: FeedlyFeedResourceId, service: FeedlyAddFeedToCollectionService, container: Container, progress: DownloadProgress, log: OSLog, customFeedName: String? = nil) throws {
|
||||
|
||||
let validator = FeedlyFeedContainerValidator(container: container)
|
||||
let (folder, collectionId) = try validator.getValidContainer()
|
||||
|
||||
self.operationQueue.suspend()
|
||||
|
||||
super.init()
|
||||
|
||||
self.downloadProgress = progress
|
||||
|
||||
let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: resource, feedName: customFeedName, collectionId: collectionId, service: service)
|
||||
addRequest.delegate = self
|
||||
addRequest.downloadProgress = progress
|
||||
self.operationQueue.add(addRequest)
|
||||
|
||||
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
|
||||
createFeeds.downloadProgress = progress
|
||||
createFeeds.addDependency(addRequest)
|
||||
self.operationQueue.add(createFeeds)
|
||||
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
finishOperation.downloadProgress = progress
|
||||
finishOperation.addDependency(createFeeds)
|
||||
self.operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
operationQueue.cancelAllOperations()
|
||||
addCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
}
|
||||
|
||||
addCompletionHandler?(.success(()))
|
||||
addCompletionHandler = nil
|
||||
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// FeedlyAddFeedToCollectionOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 11/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyAddFeedToCollectionService {
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ())
|
||||
}
|
||||
|
||||
final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding, FeedlyResourceProviding {
|
||||
|
||||
let feedName: String?
|
||||
let collectionId: String
|
||||
let service: FeedlyAddFeedToCollectionService
|
||||
let account: Account
|
||||
let folder: Folder
|
||||
let feedResource: FeedlyFeedResourceId
|
||||
|
||||
init(account: Account, folder: Folder, feedResource: FeedlyFeedResourceId, feedName: String? = nil, collectionId: String, service: FeedlyAddFeedToCollectionService) {
|
||||
self.account = account
|
||||
self.folder = folder
|
||||
self.feedResource = feedResource
|
||||
self.feedName = feedName
|
||||
self.collectionId = collectionId
|
||||
self.service = service
|
||||
}
|
||||
|
||||
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
|
||||
var resource: FeedlyResourceId {
|
||||
return feedResource
|
||||
}
|
||||
|
||||
override func run() {
|
||||
service.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionId) { [weak self] result in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
if self.isCanceled {
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
self.didCompleteRequest(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedlyAddFeedToCollectionOperation {
|
||||
|
||||
func didCompleteRequest(_ result: Result<[FeedlyFeed], Error>) {
|
||||
switch result {
|
||||
case .success(let feedlyFeeds):
|
||||
feedsAndFolders = [(feedlyFeeds, folder)]
|
||||
|
||||
let feedsWithCreatedFeedId = feedlyFeeds.filter { $0.id == resource.id }
|
||||
|
||||
if feedsWithCreatedFeedId.isEmpty {
|
||||
didFinish(with: AccountError.createErrorNotFound)
|
||||
} else {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// FeedlyAddNewFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 27/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
|
||||
class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let folder: Folder
|
||||
private let collectionId: String
|
||||
private let url: String
|
||||
private let account: Account
|
||||
private let credentials: Credentials
|
||||
private let database: SyncDatabase
|
||||
private let feedName: String?
|
||||
private let addToCollectionService: FeedlyAddFeedToCollectionService
|
||||
private let syncUnreadIdsService: FeedlyGetStreamIdsService
|
||||
private let getStreamContentsService: FeedlyGetStreamContentsService
|
||||
private let log: OSLog
|
||||
private var feedResourceId: FeedlyFeedResourceId?
|
||||
var addCompletionHandler: ((Result<Feed, Error>) -> ())?
|
||||
|
||||
init(account: Account, credentials: Credentials, url: String, feedName: String?, searchService: FeedlySearchService, addToCollectionService: FeedlyAddFeedToCollectionService, syncUnreadIdsService: FeedlyGetStreamIdsService, getStreamContentsService: FeedlyGetStreamContentsService, database: SyncDatabase, container: Container, progress: DownloadProgress, log: OSLog) throws {
|
||||
|
||||
|
||||
let validator = FeedlyFeedContainerValidator(container: container)
|
||||
(self.folder, self.collectionId) = try validator.getValidContainer()
|
||||
|
||||
self.url = url
|
||||
self.operationQueue.suspend()
|
||||
self.account = account
|
||||
self.credentials = credentials
|
||||
self.database = database
|
||||
self.feedName = feedName
|
||||
self.addToCollectionService = addToCollectionService
|
||||
self.syncUnreadIdsService = syncUnreadIdsService
|
||||
self.getStreamContentsService = getStreamContentsService
|
||||
self.log = log
|
||||
|
||||
super.init()
|
||||
|
||||
self.downloadProgress = progress
|
||||
|
||||
let search = FeedlySearchOperation(query: url, locale: .current, service: searchService)
|
||||
search.delegate = self
|
||||
search.searchDelegate = self
|
||||
search.downloadProgress = progress
|
||||
self.operationQueue.add(search)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
operationQueue.cancelAllOperations()
|
||||
addCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
override func didFinish(with error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
super.didFinish(with: error)
|
||||
}
|
||||
|
||||
func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
}
|
||||
guard let first = response.results.first else {
|
||||
return didFinish(with: AccountError.createErrorNotFound)
|
||||
}
|
||||
|
||||
let feedResourceId = FeedlyFeedResourceId(id: first.feedId)
|
||||
self.feedResourceId = feedResourceId
|
||||
|
||||
let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: feedResourceId, feedName: feedName, collectionId: collectionId, service: addToCollectionService)
|
||||
addRequest.delegate = self
|
||||
addRequest.downloadProgress = downloadProgress
|
||||
operationQueue.add(addRequest)
|
||||
|
||||
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
|
||||
createFeeds.delegate = self
|
||||
createFeeds.addDependency(addRequest)
|
||||
createFeeds.downloadProgress = downloadProgress
|
||||
operationQueue.add(createFeeds)
|
||||
|
||||
let syncUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: syncUnreadIdsService, database: database, newerThan: nil, log: log)
|
||||
syncUnread.addDependency(createFeeds)
|
||||
syncUnread.downloadProgress = downloadProgress
|
||||
syncUnread.delegate = self
|
||||
operationQueue.add(syncUnread)
|
||||
|
||||
let syncFeed = FeedlySyncStreamContentsOperation(account: account, resource: feedResourceId, service: getStreamContentsService, isPagingEnabled: false, newerThan: nil, log: log)
|
||||
syncFeed.addDependency(syncUnread)
|
||||
syncFeed.downloadProgress = downloadProgress
|
||||
syncFeed.delegate = self
|
||||
operationQueue.add(syncFeed)
|
||||
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
finishOperation.downloadProgress = downloadProgress
|
||||
finishOperation.addDependency(syncFeed)
|
||||
finishOperation.delegate = self
|
||||
operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
|
||||
os_log(.debug, log: log, "Unable to add new feed: %{public}@.", error as NSError)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
}
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
guard let handler = addCompletionHandler else {
|
||||
return
|
||||
}
|
||||
if let feedResource = feedResourceId, let feed = folder.existingFeed(withFeedID: feedResource.id) {
|
||||
handler(.success(feed))
|
||||
}
|
||||
else {
|
||||
handler(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
addCompletionHandler = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// FeedlyCheckpointOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyCheckpointOperationDelegate: AnyObject {
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation)
|
||||
}
|
||||
|
||||
/// Let the delegate know an instance is executing. The semantics are up to the delegate.
|
||||
final class FeedlyCheckpointOperation: FeedlyOperation {
|
||||
|
||||
weak var checkpointDelegate: FeedlyCheckpointOperationDelegate?
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
checkpointDelegate?.feedlyCheckpointOperationDidReachCheckpoint(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// 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: FeedlyOperation {
|
||||
|
||||
let account: Account
|
||||
let feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding
|
||||
let log: OSLog
|
||||
|
||||
init(account: Account, feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding, log: OSLog) {
|
||||
self.feedsAndFoldersProvider = feedsAndFoldersProvider
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
let pairs = feedsAndFoldersProvider.feedsAndFolders
|
||||
|
||||
let feedsBefore = Set(pairs
|
||||
.map { $0.1 }
|
||||
.flatMap { $0.topLevelFeeds })
|
||||
|
||||
// Remove feeds in a folder which are not in the corresponding collection.
|
||||
for (collectionFeeds, folder) in pairs {
|
||||
let feedsInFolder = folder.topLevelFeeds
|
||||
let feedsInCollection = Set(collectionFeeds.map { $0.id })
|
||||
let feedsToRemove = feedsInFolder.filter { !feedsInCollection.contains($0.feedID) }
|
||||
if !feedsToRemove.isEmpty {
|
||||
folder.removeFeeds(feedsToRemove)
|
||||
// os_log(.debug, log: log, "\"%@\" - removed: %@", collection.label, feedsToRemove.map { $0.feedID }, feedsInCollection)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Pair each Feed with its Folder.
|
||||
var feedsAdded = Set<Feed>()
|
||||
|
||||
let feedsAndFolders = pairs
|
||||
.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 previously added to the account
|
||||
if let feed = account.existingFeed(withFeedID: collectionFeed.id) {
|
||||
|
||||
// If the feed was renamed on Feedly, ensure we ingest the new name.
|
||||
if feed.nameForDisplay != collectionFeed.title {
|
||||
feed.name = collectionFeed.title
|
||||
|
||||
// Let the rest of the app (e.g.: the sidebar) know the feed name changed
|
||||
// `editedName` would post this if its value is changing.
|
||||
// Setting the `name` property has no side effects like this.
|
||||
if feed.editedName != nil {
|
||||
feed.editedName = nil
|
||||
} else {
|
||||
feed.postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
return (feed, folder)
|
||||
} else {
|
||||
// find an existing feed we created below in an earlier value
|
||||
for feed in feedsAdded where feed.feedID == collectionFeed.id {
|
||||
return (feed, folder)
|
||||
}
|
||||
}
|
||||
|
||||
// no existing feed, create a new one
|
||||
let parser = FeedlyFeedParser(feed: collectionFeed)
|
||||
let feed = account.createFeed(with: parser.title,
|
||||
url: parser.url,
|
||||
feedID: parser.feedID,
|
||||
homePageURL: parser.homePageURL)
|
||||
|
||||
// So the same feed isn't created more than once.
|
||||
feedsAdded.insert(feed)
|
||||
|
||||
return (feed, folder)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Processing %i feeds.", feedsAndFolders.count)
|
||||
for (feed, folder) in feedsAndFolders {
|
||||
if !folder.has(feed) {
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove feeds without folders/collections.
|
||||
let feedsAfter = Set(feedsAndFolders.map { $0.0 })
|
||||
let feedsWithoutCollections = feedsBefore.subtracting(feedsAfter)
|
||||
account.removeFeeds(feedsWithoutCollections)
|
||||
|
||||
if !feedsWithoutCollections.isEmpty {
|
||||
os_log(.debug, log: log, "Removed %i feeds", feedsWithoutCollections.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// FeedlyDownloadArticlesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSWeb
|
||||
|
||||
class FeedlyDownloadArticlesOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let log: OSLog
|
||||
private let missingArticleEntryIdProvider: FeedlyEntryIdentifierProviding
|
||||
private let updatedArticleEntryIdProvider: FeedlyEntryIdentifierProviding
|
||||
private let getEntriesService: FeedlyGetEntriesService
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
init(account: Account, missingArticleEntryIdProvider: FeedlyEntryIdentifierProviding, updatedArticleEntryIdProvider: FeedlyEntryIdentifierProviding, getEntriesService: FeedlyGetEntriesService, log: OSLog) {
|
||||
self.account = account
|
||||
self.operationQueue.suspend()
|
||||
self.missingArticleEntryIdProvider = missingArticleEntryIdProvider
|
||||
self.updatedArticleEntryIdProvider = updatedArticleEntryIdProvider
|
||||
self.getEntriesService = getEntriesService
|
||||
self.finishOperation = FeedlyCheckpointOperation()
|
||||
self.log = log
|
||||
super.init()
|
||||
self.finishOperation.checkpointDelegate = self
|
||||
self.operationQueue.add(self.finishOperation)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
var articleIds = missingArticleEntryIdProvider.entryIds
|
||||
articleIds.formUnion(updatedArticleEntryIdProvider.entryIds)
|
||||
|
||||
os_log(.debug, log: log, "Requesting %{public}i articles.", articleIds.count)
|
||||
|
||||
let feedlyAPILimitBatchSize = 1000
|
||||
for articleIds in Array(articleIds).chunked(into: feedlyAPILimitBatchSize) {
|
||||
|
||||
let provider = FeedlyEntryIdentifierProvider(entryIds: Set(articleIds))
|
||||
let getEntries = FeedlyGetEntriesOperation(account: account, service: getEntriesService, provider: provider, log: log)
|
||||
getEntries.delegate = self
|
||||
self.operationQueue.add(getEntries)
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
|
||||
parsedItemProvider: getEntries,
|
||||
log: log)
|
||||
organiseByFeed.delegate = self
|
||||
organiseByFeed.addDependency(getEntries)
|
||||
self.operationQueue.add(organiseByFeed)
|
||||
|
||||
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
|
||||
organisedItemsProvider: organiseByFeed,
|
||||
log: log)
|
||||
|
||||
updateAccount.delegate = self
|
||||
updateAccount.addDependency(organiseByFeed)
|
||||
self.operationQueue.add(updateAccount)
|
||||
|
||||
finishOperation.addDependency(updateAccount)
|
||||
}
|
||||
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
// TODO: fix error on below line: "Expression type '()' is ambiguous without more context"
|
||||
//os_log(.debug, log: log, "Cancelling %{public}@.", self)
|
||||
operationQueue.cancelAllOperations()
|
||||
super.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyDownloadArticlesOperation: FeedlyCheckpointOperationDelegate {
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyDownloadArticlesOperation: FeedlyOperationDelegate {
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
// Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example.
|
||||
os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError)
|
||||
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// FeedlyFetchIdsForMissingArticlesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 7/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
final class FeedlyFetchIdsForMissingArticlesOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
private let account: Account
|
||||
private let log: OSLog
|
||||
|
||||
private(set) var entryIds = Set<String>()
|
||||
|
||||
init(account: Account, log: OSLog) {
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in
|
||||
switch result {
|
||||
case .success(let articleIds):
|
||||
self.entryIds.formUnion(articleIds)
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// FeedlyGetCollectionsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
protocol FeedlyCollectionProviding: AnyObject {
|
||||
var collections: [FeedlyCollection] { get }
|
||||
}
|
||||
|
||||
/// Get Collections from Feedly.
|
||||
final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding {
|
||||
|
||||
let service: FeedlyGetCollectionsService
|
||||
let log: OSLog
|
||||
|
||||
private(set) var collections = [FeedlyCollection]()
|
||||
|
||||
init(service: FeedlyGetCollectionsService, log: OSLog) {
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
os_log(.debug, log: log, "Requesting collections.")
|
||||
|
||||
service.getCollections { result in
|
||||
switch result {
|
||||
case .success(let collections):
|
||||
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
|
||||
self.collections = collections
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// FeedlyGetEntriesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 28/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
|
||||
/// Get full entries for the entry identifiers.
|
||||
final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
|
||||
|
||||
let account: Account
|
||||
let service: FeedlyGetEntriesService
|
||||
let provider: FeedlyEntryIdentifierProviding
|
||||
let log: OSLog
|
||||
|
||||
init(account: Account, service: FeedlyGetEntriesService, provider: FeedlyEntryIdentifierProviding, log: OSLog) {
|
||||
self.account = account
|
||||
self.service = service
|
||||
self.provider = provider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
private(set) var entries = [FeedlyEntry]()
|
||||
|
||||
private var storedParsedEntries: Set<ParsedItem>?
|
||||
|
||||
var parsedEntries: Set<ParsedItem> {
|
||||
if let entries = storedParsedEntries {
|
||||
return entries
|
||||
}
|
||||
|
||||
let parsed = Set(entries.compactMap {
|
||||
FeedlyEntryParser(entry: $0).parsedItemRepresentation
|
||||
})
|
||||
|
||||
// TODO: Fix the below. There’s an error on the os.log line: "Expression type '()' is ambiguous without more context"
|
||||
// if parsed.count != entries.count {
|
||||
// let entryIds = Set(entries.map { $0.id })
|
||||
// let parsedIds = Set(parsed.map { $0.uniqueID })
|
||||
// let difference = entryIds.subtracting(parsedIds)
|
||||
// os_log(.debug, log: log, "%{public}@ dropping articles with ids: %{public}@.", self, difference)
|
||||
// }
|
||||
|
||||
storedParsedEntries = parsed
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
var parsedItemProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
service.getEntries(for: provider.entryIds) { result in
|
||||
switch result {
|
||||
case .success(let entries):
|
||||
self.entries = entries
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get entries: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// FeedlyGetStreamOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
|
||||
protocol FeedlyEntryProviding {
|
||||
var entries: [FeedlyEntry] { get }
|
||||
}
|
||||
|
||||
protocol FeedlyParsedItemProviding {
|
||||
var parsedItemProviderName: String { get }
|
||||
var parsedEntries: Set<ParsedItem> { get }
|
||||
}
|
||||
|
||||
protocol FeedlyGetStreamContentsOperationDelegate: AnyObject {
|
||||
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream)
|
||||
}
|
||||
|
||||
/// Get the stream content of a Collection from Feedly.
|
||||
final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
|
||||
|
||||
struct ResourceProvider: FeedlyResourceProviding {
|
||||
var resource: FeedlyResourceId
|
||||
}
|
||||
|
||||
let resourceProvider: FeedlyResourceProviding
|
||||
|
||||
var parsedItemProviderName: String {
|
||||
return resourceProvider.resource.id
|
||||
}
|
||||
|
||||
var entries: [FeedlyEntry] {
|
||||
guard let entries = stream?.items else {
|
||||
// assert(isFinished, "This should only be called when the operation finishes without error.")
|
||||
assertionFailure("Has this operation been addeded as a dependency on the caller?")
|
||||
return []
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
var parsedEntries: Set<ParsedItem> {
|
||||
if let entries = storedParsedEntries {
|
||||
return entries
|
||||
}
|
||||
|
||||
let parsed = Set(entries.compactMap {
|
||||
FeedlyEntryParser(entry: $0).parsedItemRepresentation
|
||||
})
|
||||
|
||||
if parsed.count != entries.count {
|
||||
let entryIds = Set(entries.map { $0.id })
|
||||
let parsedIds = Set(parsed.map { $0.uniqueID })
|
||||
let difference = entryIds.subtracting(parsedIds)
|
||||
os_log(.debug, log: log, "Dropping articles with ids: %{public}@.", difference)
|
||||
}
|
||||
|
||||
storedParsedEntries = parsed
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
private(set) var stream: FeedlyStream? {
|
||||
didSet {
|
||||
storedParsedEntries = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var storedParsedEntries: Set<ParsedItem>?
|
||||
|
||||
let account: Account
|
||||
let service: FeedlyGetStreamContentsService
|
||||
let unreadOnly: Bool?
|
||||
let newerThan: Date?
|
||||
let continuation: String?
|
||||
let log: OSLog
|
||||
|
||||
weak var streamDelegate: FeedlyGetStreamContentsOperationDelegate?
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
self.account = account
|
||||
self.resourceProvider = ResourceProvider(resource: resource)
|
||||
self.service = service
|
||||
self.continuation = continuation
|
||||
self.unreadOnly = unreadOnly
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
convenience init(account: Account, resourceProvider: FeedlyResourceProviding, service: FeedlyGetStreamContentsService, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
self.init(account: account, resource: resourceProvider.resource, service: service, newerThan: newerThan, unreadOnly: unreadOnly, log: log)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
|
||||
switch result {
|
||||
case .success(let stream):
|
||||
self.stream = stream
|
||||
|
||||
self.streamDelegate?.feedlyGetStreamContentsOperation(self, didGetContentsOf: stream)
|
||||
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// FeedlyGetStreamIdsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
protocol FeedlyGetStreamIdsOperationDelegate: AnyObject {
|
||||
func feedlyGetStreamIdsOperation(_ operation: FeedlyGetStreamIdsOperation, didGet streamIds: FeedlyStreamIds)
|
||||
}
|
||||
|
||||
/// Single responsibility is to get the stream ids from Feedly.
|
||||
final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
var entryIds: Set<String> {
|
||||
guard let ids = streamIds?.ids else {
|
||||
assertionFailure("Has this operation been addeded as a dependency on the caller?")
|
||||
return []
|
||||
}
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
private(set) var streamIds: FeedlyStreamIds?
|
||||
|
||||
let account: Account
|
||||
let service: FeedlyGetStreamIdsService
|
||||
let continuation: String?
|
||||
let resource: FeedlyResourceId
|
||||
let unreadOnly: Bool?
|
||||
let newerThan: Date?
|
||||
let log: OSLog
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, continuation: String? = nil, newerThan: Date? = nil, unreadOnly: Bool?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.continuation = continuation
|
||||
self.newerThan = newerThan
|
||||
self.unreadOnly = unreadOnly
|
||||
self.log = log
|
||||
}
|
||||
|
||||
weak var streamIdsDelegate: FeedlyGetStreamIdsOperationDelegate?
|
||||
|
||||
override func run() {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
|
||||
switch result {
|
||||
case .success(let stream):
|
||||
self.streamIds = stream
|
||||
|
||||
self.streamIdsDelegate?.feedlyGetStreamIdsOperation(self, didGet: stream)
|
||||
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get stream ids: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// FeedlyGetUpdatedArticleIdsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 11/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Secrets
|
||||
|
||||
/// Single responsibility is to identify articles that have changed since a particular date.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.all stream.
|
||||
/// When all the article ids are collected, it is the responsibility of another operation to download them when appropriate.
|
||||
class FeedlyGetUpdatedArticleIdsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceId
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let newerThan: Date?
|
||||
private let log: OSLog
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
var entryIds: Set<String> {
|
||||
return storedUpdatedArticleIds
|
||||
}
|
||||
|
||||
private var storedUpdatedArticleIds = Set<String>()
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
guard let date = newerThan else {
|
||||
os_log(.debug, log: log, "No date provided so everything must be new (nothing is updated).")
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: date, unreadOnly: nil, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
storedUpdatedArticleIds.formUnion(streamIds.ids)
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
os_log(.debug, log: log, "%{public}i articles updated since last successful sync start date.", storedUpdatedArticleIds.count)
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
getStreamIds(continuation)
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// FeedlyIngestStarredArticleIdsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 15/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
|
||||
/// Clone locally the remote starred article state.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.saved stream.
|
||||
/// When all the article ids are collected, a status is created for each.
|
||||
/// The article ids previously marked as starred but not collected become unstarred.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
final class FeedlyIngestStarredArticleIdsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceId
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let database: SyncDatabase
|
||||
private var remoteEntryIds = Set<String>()
|
||||
private let log: OSLog
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
let resource = FeedlyTagResourceId.Global.saved(for: userId)
|
||||
self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.database = database
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
|
||||
remoteEntryIds.formUnion(streamIds.ids)
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
removeEntryIdsWithPendingStatus()
|
||||
return
|
||||
}
|
||||
|
||||
getStreamIds(continuation)
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Do not override pending statuses with the remote statuses of the same articles, otherwise an article will temporarily re-acquire the remote status before the pending status is pushed and subseqently pulled.
|
||||
private func removeEntryIdsWithPendingStatus() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
database.selectPendingStarredStatusArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let pendingArticleIds):
|
||||
self.remoteEntryIds.subtract(pendingArticleIds)
|
||||
|
||||
self.updateStarredStatuses()
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStarredStatuses() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
account.fetchStarredArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let localStarredArticleIDs):
|
||||
self.processStarredArticleIDs(localStarredArticleIDs)
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processStarredArticleIDs(_ localStarredArticleIDs: Set<String>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
let remoteStarredArticleIDs = remoteEntryIds
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
final class StarredStatusResults {
|
||||
var markAsStarredError: Error?
|
||||
var markAsUnstarredError: Error?
|
||||
}
|
||||
|
||||
let results = StarredStatusResults()
|
||||
|
||||
group.enter()
|
||||
account.markAsStarred(remoteStarredArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsStarredError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs)
|
||||
group.enter()
|
||||
account.markAsUnstarred(deltaUnstarredArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsUnstarredError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
let markingError = results.markAsStarredError ?? results.markAsUnstarredError
|
||||
guard let error = markingError else {
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// FeedlyIngestStreamArticleIdsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Secrets
|
||||
|
||||
/// Ensure a status exists for every article id the user might be interested in.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.all stream.
|
||||
/// As the article ids are collected, a default read status is created for each.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
class FeedlyIngestStreamArticleIdsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceId
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let log: OSLog
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: all, service: service, log: log)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
account.createStatusesIfNeeded(articleIDs: Set(streamIds.ids)) { databaseError in
|
||||
|
||||
if let error = databaseError {
|
||||
self.didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
os_log(.debug, log: self.log, "Reached end of stream for %@", self.resource.id)
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
self.getStreamIds(continuation)
|
||||
}
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// FeedlyIngestUnreadArticleIdsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
|
||||
/// Clone locally the remote unread article state.
|
||||
///
|
||||
/// Typically, it pages through the unread article ids of the global.all stream.
|
||||
/// When all the unread article ids are collected, a status is created for each.
|
||||
/// The article ids previously marked as unread but not collected become read.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceId
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let database: SyncDatabase
|
||||
private var remoteEntryIds = Set<String>()
|
||||
private let log: OSLog
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
let resource = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.database = database
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: true, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
|
||||
remoteEntryIds.formUnion(streamIds.ids)
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
removeEntryIdsWithPendingStatus()
|
||||
return
|
||||
}
|
||||
|
||||
getStreamIds(continuation)
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Do not override pending statuses with the remote statuses of the same articles, otherwise an article will temporarily re-acquire the remote status before the pending status is pushed and subseqently pulled.
|
||||
private func removeEntryIdsWithPendingStatus() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
database.selectPendingReadStatusArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let pendingArticleIds):
|
||||
self.remoteEntryIds.subtract(pendingArticleIds)
|
||||
|
||||
self.updateUnreadStatuses()
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUnreadStatuses() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
account.fetchUnreadArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let localUnreadArticleIDs):
|
||||
self.processUnreadArticleIDs(localUnreadArticleIDs)
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processUnreadArticleIDs(_ localUnreadArticleIDs: Set<String>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
let remoteUnreadArticleIDs = remoteEntryIds
|
||||
let group = DispatchGroup()
|
||||
|
||||
final class ReadStatusResults {
|
||||
var markAsUnreadError: Error?
|
||||
var markAsReadError: Error?
|
||||
}
|
||||
|
||||
let results = ReadStatusResults()
|
||||
|
||||
group.enter()
|
||||
account.markAsUnread(remoteUnreadArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsUnreadError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs)
|
||||
group.enter()
|
||||
account.markAsRead(articleIDsToMarkRead) { result in
|
||||
if case .failure(let error) = result {
|
||||
results.markAsReadError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
let markingError = results.markAsReadError ?? results.markAsUnreadError
|
||||
guard let error = markingError else {
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// FeedlyLogoutOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 15/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
protocol FeedlyLogoutService {
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> ())
|
||||
}
|
||||
|
||||
final class FeedlyLogoutOperation: FeedlyOperation {
|
||||
|
||||
let service: FeedlyLogoutService
|
||||
let account: Account
|
||||
let log: OSLog
|
||||
|
||||
init(account: Account, service: FeedlyLogoutService, log: OSLog) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
os_log("Requesting logout of %{public}@ account.", "\(account.type)")
|
||||
service.logout(completion: didCompleteLogout(_:))
|
||||
}
|
||||
|
||||
func didCompleteLogout(_ result: Result<Void, Error>) {
|
||||
assert(Thread.isMainThread)
|
||||
switch result {
|
||||
case .success:
|
||||
os_log("Logged out of %{public}@ account.", "\(account.type)")
|
||||
do {
|
||||
try account.removeCredentials(type: .oauthAccessToken)
|
||||
try account.removeCredentials(type: .oauthRefreshToken)
|
||||
} catch {
|
||||
// oh well, we tried our best.
|
||||
}
|
||||
didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
os_log("Logout failed because %{public}@.", error as NSError)
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// FeedlyMirrorCollectionsAsFoldersOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
protocol FeedlyFeedsAndFoldersProviding {
|
||||
var feedsAndFolders: [([FeedlyFeed], Folder)] { get }
|
||||
}
|
||||
|
||||
/// Reflect Collections from Feedly as Folders.
|
||||
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding {
|
||||
|
||||
let account: Account
|
||||
let collectionsProvider: FeedlyCollectionProviding
|
||||
let log: OSLog
|
||||
|
||||
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
|
||||
init(account: Account, collectionsProvider: FeedlyCollectionProviding, log: OSLog) {
|
||||
self.collectionsProvider = collectionsProvider
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
let localFolders = account.folders ?? Set()
|
||||
let collections = collectionsProvider.collections
|
||||
|
||||
feedsAndFolders = collections.compactMap { collection -> ([FeedlyFeed], Folder)? in
|
||||
let parser = FeedlyCollectionParser(collection: collection)
|
||||
guard let folder = account.ensureFolder(with: parser.folderName) else {
|
||||
assertionFailure("Why wasn't a folder created?")
|
||||
return nil
|
||||
}
|
||||
folder.externalID = parser.externalID
|
||||
return (collection.feeds, folder)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Ensured %i folders for %i collections.", feedsAndFolders.count, collections.count)
|
||||
|
||||
// Remove folders without a corresponding collection
|
||||
let collectionFolders = Set(feedsAndFolders.map { $0.1 })
|
||||
let foldersWithoutCollections = localFolders.subtracting(collectionFolders)
|
||||
|
||||
if !foldersWithoutCollections.isEmpty {
|
||||
for unmatched in foldersWithoutCollections {
|
||||
account.removeFolder(unmatched)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// FeedlyOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
protocol FeedlyOperationDelegate: AnyObject {
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
|
||||
}
|
||||
|
||||
/// Abstract base class for Feedly sync operations.
|
||||
///
|
||||
/// Normally we don’t do inheritance — but in this case
|
||||
/// it’s the best option.
|
||||
class FeedlyOperation: MainThreadOperation {
|
||||
|
||||
weak var delegate: FeedlyOperationDelegate?
|
||||
var downloadProgress: DownloadProgress? {
|
||||
didSet {
|
||||
oldValue?.completeTask()
|
||||
downloadProgress?.addToNumberOfTasksAndRemaining(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MainThreadOperation
|
||||
var isCanceled = false {
|
||||
didSet {
|
||||
if isCanceled {
|
||||
didCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
var id: Int?
|
||||
weak var operationDelegate: MainThreadOperationDelegate?
|
||||
var name: String?
|
||||
var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
func run() {
|
||||
}
|
||||
|
||||
func didFinish() {
|
||||
if !isCanceled {
|
||||
operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
downloadProgress?.completeTask()
|
||||
}
|
||||
|
||||
func didFinish(with error: Error) {
|
||||
delegate?.feedlyOperation(self, didFailWith: error)
|
||||
didFinish()
|
||||
}
|
||||
|
||||
func didCancel() {
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// FeedlyOrganiseParsedItemsByFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
|
||||
protocol FeedlyParsedItemsByFeedProviding {
|
||||
var parsedItemsByFeedProviderName: String { get }
|
||||
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>] { get }
|
||||
}
|
||||
|
||||
/// Group articles by their feeds.
|
||||
final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding {
|
||||
|
||||
private let account: Account
|
||||
private let parsedItemProvider: FeedlyParsedItemProviding
|
||||
private let log: OSLog
|
||||
|
||||
var parsedItemsByFeedProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
var parsedItemsKeyedByFeedId: [String : Set<ParsedItem>] {
|
||||
precondition(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type.
|
||||
return itemsKeyedByFeedId
|
||||
}
|
||||
|
||||
private var itemsKeyedByFeedId = [String: Set<ParsedItem>]()
|
||||
|
||||
init(account: Account, parsedItemProvider: FeedlyParsedItemProviding, log: OSLog) {
|
||||
self.account = account
|
||||
self.parsedItemProvider = parsedItemProvider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
let items = parsedItemProvider.parsedEntries
|
||||
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
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.parsedItemProviderName)
|
||||
|
||||
itemsKeyedByFeedId = dict
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// FeedlyRefreshAccessTokenOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 4/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
|
||||
|
||||
let service: OAuthAccessTokenRefreshing
|
||||
let oauthClient: OAuthAuthorizationClient
|
||||
let account: Account
|
||||
let log: OSLog
|
||||
|
||||
init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, log: OSLog) {
|
||||
self.oauthClient = oauthClient
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
let refreshToken: Credentials
|
||||
|
||||
do {
|
||||
guard let credentials = try account.retrieveCredentials(type: .oauthRefreshToken) else {
|
||||
os_log(.debug, log: log, "Could not find a refresh token in the keychain. Check the refresh token is added to the Keychain, remove the account and add it again.")
|
||||
throw TransportError.httpError(status: 403)
|
||||
}
|
||||
|
||||
refreshToken = credentials
|
||||
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Refreshing access token.")
|
||||
|
||||
// Ignore cancellation after the request is resumed otherwise we may continue storing a potentially invalid token!
|
||||
service.refreshAccessToken(with: refreshToken.secret, client: oauthClient) { result in
|
||||
self.didRefreshAccessToken(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func didRefreshAccessToken(_ result: Result<OAuthAuthorizationGrant, Error>) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
switch result {
|
||||
case .success(let grant):
|
||||
do {
|
||||
os_log(.debug, log: log, "Storing refresh token.")
|
||||
// Store the refresh token first because it sends this token to the account delegate.
|
||||
if let token = grant.refreshToken {
|
||||
try account.storeCredentials(token)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Storing access token.")
|
||||
// Now store the access token because we want the account delegate to use it.
|
||||
try account.storeCredentials(grant.accessToken)
|
||||
|
||||
didFinish()
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// 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: AnyObject {
|
||||
func feedlyRequestStreamsOperation(_ operation: FeedlyRequestStreamsOperation, enqueue collectionStreamOperation: FeedlyGetStreamContentsOperation)
|
||||
}
|
||||
|
||||
/// 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: FeedlyOperation {
|
||||
|
||||
weak var queueDelegate: FeedlyRequestStreamsOperationDelegate?
|
||||
|
||||
let collectionsProvider: FeedlyCollectionProviding
|
||||
let service: FeedlyGetStreamContentsService
|
||||
let account: Account
|
||||
let log: OSLog
|
||||
let newerThan: Date?
|
||||
let unreadOnly: Bool?
|
||||
|
||||
init(account: Account, collectionsProvider: FeedlyCollectionProviding, newerThan: Date?, unreadOnly: Bool?, service: FeedlyGetStreamContentsService, log: OSLog) {
|
||||
self.account = account
|
||||
self.service = service
|
||||
self.collectionsProvider = collectionsProvider
|
||||
self.newerThan = newerThan
|
||||
self.unreadOnly = unreadOnly
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
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 resource = FeedlyCategoryResourceId(id: collection.id)
|
||||
let operation = FeedlyGetStreamContentsOperation(account: account,
|
||||
resource: resource,
|
||||
service: service,
|
||||
newerThan: newerThan,
|
||||
unreadOnly: unreadOnly,
|
||||
log: log)
|
||||
queueDelegate?.feedlyRequestStreamsOperation(self, enqueue: operation)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Requested %i collection streams", collectionsProvider.collections.count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// FeedlySearchOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 1/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlySearchService: AnyObject {
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> ())
|
||||
}
|
||||
|
||||
protocol FeedlySearchOperationDelegate: AnyObject {
|
||||
func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse)
|
||||
}
|
||||
|
||||
/// Find one and only one feed for a given query (usually, a URL).
|
||||
/// What happens when a feed is found for the URL is delegated to the `searchDelegate`.
|
||||
class FeedlySearchOperation: FeedlyOperation {
|
||||
|
||||
let query: String
|
||||
let locale: Locale
|
||||
let searchService: FeedlySearchService
|
||||
weak var searchDelegate: FeedlySearchOperationDelegate?
|
||||
|
||||
init(query: String, locale: Locale = .current, service: FeedlySearchService) {
|
||||
self.query = query
|
||||
self.locale = locale
|
||||
self.searchService = service
|
||||
}
|
||||
|
||||
override func run() {
|
||||
searchService.getFeeds(for: query, count: 1, locale: locale.identifier) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
assert(Thread.isMainThread)
|
||||
self.searchDelegate?.feedlySearchOperation(self, didGet: response)
|
||||
self.didFinish()
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// FeedlySendArticleStatusesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
|
||||
|
||||
/// Take changes to statuses of articles locally and apply them to the corresponding the articles remotely.
|
||||
final class FeedlySendArticleStatusesOperation: FeedlyOperation {
|
||||
|
||||
private let database: SyncDatabase
|
||||
private let log: OSLog
|
||||
private let service: FeedlyMarkArticlesService
|
||||
|
||||
init(database: SyncDatabase, service: FeedlyMarkArticlesService, log: OSLog) {
|
||||
self.database = database
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
os_log(.debug, log: log, "Sending article statuses...")
|
||||
|
||||
database.selectForProcessing { result in
|
||||
if self.isCanceled {
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let syncStatuses):
|
||||
self.processStatuses(syncStatuses)
|
||||
case .failure:
|
||||
self.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedlySendArticleStatusesOperation {
|
||||
|
||||
func processStatuses(_ pending: [SyncStatus]) {
|
||||
let statuses: [(status: SyncStatus.Key, flag: Bool, action: FeedlyMarkAction)] = [
|
||||
(.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()
|
||||
service.mark(ids, as: pairing.action) { result in
|
||||
assert(Thread.isMainThread)
|
||||
switch result {
|
||||
case .success:
|
||||
database.deleteSelectedForProcessing(Array(ids)) { _ in
|
||||
group.leave()
|
||||
}
|
||||
case .failure:
|
||||
database.resetSelectedForProcessing(Array(ids)) { _ in
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
self.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// FeedlySyncAllOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
|
||||
/// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past.
|
||||
final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let log: OSLog
|
||||
let syncUUID: UUID
|
||||
|
||||
var syncCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
|
||||
/// These requests to Feedly determine which articles to download:
|
||||
/// 1. The set of all article ids we might need or show.
|
||||
/// 2. The set of all unread article ids we might need or show (a subset of 1).
|
||||
/// 3. The set of all article ids changed since the last sync (a subset of 1).
|
||||
/// 4. The set of all starred article ids.
|
||||
///
|
||||
/// On the response for 1, create statuses for each article id.
|
||||
/// On the response for 2, create unread statuses for each article id and mark as read those no longer in the response.
|
||||
/// On the response for 4, create starred statuses for each article id and mark as unstarred those no longer in the response.
|
||||
///
|
||||
/// Download articles for statuses at the union of those statuses without its corresponding article and those included in 3 (changed since last successful sync).
|
||||
///
|
||||
init(account: Account, feedlyUserId: String, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredService: FeedlyGetStreamIdsService, getStreamIdsService: FeedlyGetStreamIdsService, getEntriesService: FeedlyGetEntriesService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) {
|
||||
self.syncUUID = UUID()
|
||||
self.log = log
|
||||
self.operationQueue.suspend()
|
||||
|
||||
super.init()
|
||||
|
||||
self.downloadProgress = downloadProgress
|
||||
|
||||
// Send any read/unread/starred article statuses to Feedly before anything else.
|
||||
let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, service: markArticlesService, log: log)
|
||||
sendArticleStatuses.delegate = self
|
||||
sendArticleStatuses.downloadProgress = downloadProgress
|
||||
self.operationQueue.add(sendArticleStatuses)
|
||||
|
||||
// Get all the Collections the user has.
|
||||
let getCollections = FeedlyGetCollectionsOperation(service: getCollectionsService, log: log)
|
||||
getCollections.delegate = self
|
||||
getCollections.downloadProgress = downloadProgress
|
||||
getCollections.addDependency(sendArticleStatuses)
|
||||
self.operationQueue.add(getCollections)
|
||||
|
||||
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
|
||||
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: log)
|
||||
mirrorCollectionsAsFolders.delegate = self
|
||||
mirrorCollectionsAsFolders.addDependency(getCollections)
|
||||
self.operationQueue.add(mirrorCollectionsAsFolders)
|
||||
|
||||
// Ensure feeds are created and grouped by their folders.
|
||||
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: mirrorCollectionsAsFolders, log: log)
|
||||
createFeedsOperation.delegate = self
|
||||
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
|
||||
self.operationQueue.add(createFeedsOperation)
|
||||
|
||||
let getAllArticleIds = FeedlyIngestStreamArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, log: log)
|
||||
getAllArticleIds.delegate = self
|
||||
getAllArticleIds.downloadProgress = downloadProgress
|
||||
getAllArticleIds.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getAllArticleIds)
|
||||
|
||||
// Get each page of unread article ids in the global.all stream for the last 31 days (nil = Feedly API default).
|
||||
let getUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: feedlyUserId, service: getUnreadService, database: database, newerThan: nil, log: log)
|
||||
getUnread.delegate = self
|
||||
getUnread.addDependency(getAllArticleIds)
|
||||
getUnread.downloadProgress = downloadProgress
|
||||
self.operationQueue.add(getUnread)
|
||||
|
||||
// Get each page of the article ids which have been update since the last successful fetch start date.
|
||||
// If the date is nil, this operation provides an empty set (everything is new, nothing is updated).
|
||||
let getUpdated = FeedlyGetUpdatedArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, newerThan: lastSuccessfulFetchStartDate, log: log)
|
||||
getUpdated.delegate = self
|
||||
getUpdated.downloadProgress = downloadProgress
|
||||
getUpdated.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getUpdated)
|
||||
|
||||
// Get each page of the article ids for starred articles.
|
||||
let getStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: feedlyUserId, service: getStarredService, database: database, newerThan: nil, log: log)
|
||||
getStarred.delegate = self
|
||||
getStarred.downloadProgress = downloadProgress
|
||||
getStarred.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getStarred)
|
||||
|
||||
// Now all the possible article ids we need have a status, fetch the article ids for missing articles.
|
||||
let getMissingIds = FeedlyFetchIdsForMissingArticlesOperation(account: account, log: log)
|
||||
getMissingIds.delegate = self
|
||||
getMissingIds.downloadProgress = downloadProgress
|
||||
getMissingIds.addDependency(getAllArticleIds)
|
||||
getMissingIds.addDependency(getUnread)
|
||||
getMissingIds.addDependency(getStarred)
|
||||
getMissingIds.addDependency(getUpdated)
|
||||
self.operationQueue.add(getMissingIds)
|
||||
|
||||
// Download all the missing and updated articles
|
||||
let downloadMissingArticles = FeedlyDownloadArticlesOperation(account: account,
|
||||
missingArticleEntryIdProvider: getMissingIds,
|
||||
updatedArticleEntryIdProvider: getUpdated,
|
||||
getEntriesService: getEntriesService,
|
||||
log: log)
|
||||
downloadMissingArticles.delegate = self
|
||||
downloadMissingArticles.downloadProgress = downloadProgress
|
||||
downloadMissingArticles.addDependency(getMissingIds)
|
||||
downloadMissingArticles.addDependency(getUpdated)
|
||||
self.operationQueue.add(downloadMissingArticles)
|
||||
|
||||
// Once this operation's dependencies, their dependencies etc finish, we can finish.
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
finishOperation.downloadProgress = downloadProgress
|
||||
finishOperation.addDependency(downloadMissingArticles)
|
||||
self.operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
convenience init(account: Account, feedlyUserId: String, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, downloadProgress: DownloadProgress, log: OSLog) {
|
||||
self.init(account: account, feedlyUserId: feedlyUserId, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredService: caller, getStreamIdsService: caller, getEntriesService: caller, database: database, downloadProgress: downloadProgress, log: log)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
os_log(.debug, log: log, "Starting sync %{public}@", syncUUID.uuidString)
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
os_log(.debug, log: log, "Cancelling sync %{public}@", syncUUID.uuidString)
|
||||
self.operationQueue.cancelAllOperations()
|
||||
syncCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlySyncAllOperation: FeedlyCheckpointOperationDelegate {
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
assert(Thread.isMainThread)
|
||||
os_log(.debug, log: self.log, "Sync completed: %{public}@", syncUUID.uuidString)
|
||||
|
||||
syncCompletionHandler?(.success(()))
|
||||
syncCompletionHandler = nil
|
||||
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlySyncAllOperation: FeedlyOperationDelegate {
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
// Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example.
|
||||
os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError)
|
||||
|
||||
syncCompletionHandler?(.failure(error))
|
||||
syncCompletionHandler = nil
|
||||
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// FeedlySyncStreamContentsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 17/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceId
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let service: FeedlyGetStreamContentsService
|
||||
private let newerThan: Date?
|
||||
private let isPagingEnabled: Bool
|
||||
private let log: OSLog
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, isPagingEnabled: Bool, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.isPagingEnabled = isPagingEnabled
|
||||
self.operationQueue.suspend()
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
self.finishOperation = FeedlyCheckpointOperation()
|
||||
|
||||
super.init()
|
||||
|
||||
self.operationQueue.add(self.finishOperation)
|
||||
self.finishOperation.checkpointDelegate = self
|
||||
enqueueOperations(for: nil)
|
||||
}
|
||||
|
||||
convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: credentials.username)
|
||||
self.init(account: account, resource: all, service: service, isPagingEnabled: true, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
os_log(.debug, log: log, "Canceling sync stream contents for %{public}@", resource.id)
|
||||
operationQueue.cancelAllOperations()
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
func enqueueOperations(for continuation: String?) {
|
||||
os_log(.debug, log: log, "Requesting page for %{public}@", resource.id)
|
||||
let operations = pageOperations(for: continuation)
|
||||
operationQueue.addOperations(operations)
|
||||
}
|
||||
|
||||
func pageOperations(for continuation: String?) -> [MainThreadOperation] {
|
||||
let getPage = FeedlyGetStreamContentsOperation(account: account,
|
||||
resource: resource,
|
||||
service: service,
|
||||
continuation: continuation,
|
||||
newerThan: newerThan,
|
||||
log: log)
|
||||
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: getPage, log: log)
|
||||
|
||||
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: organiseByFeed, log: log)
|
||||
|
||||
getPage.delegate = self
|
||||
getPage.streamDelegate = self
|
||||
|
||||
organiseByFeed.addDependency(getPage)
|
||||
organiseByFeed.delegate = self
|
||||
|
||||
updateAccount.addDependency(organiseByFeed)
|
||||
updateAccount.delegate = self
|
||||
|
||||
finishOperation.addDependency(updateAccount)
|
||||
|
||||
return [getPage, organiseByFeed, updateAccount]
|
||||
}
|
||||
|
||||
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) {
|
||||
guard !isCanceled else {
|
||||
os_log(.debug, log: log, "Cancelled requesting page for %{public}@", resource.id)
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Ingesting %i items from %{public}@", stream.items.count, stream.id)
|
||||
|
||||
guard isPagingEnabled, let continuation = stream.continuation else {
|
||||
os_log(.debug, log: log, "Reached end of stream for %{public}@", stream.id)
|
||||
return
|
||||
}
|
||||
|
||||
enqueueOperations(for: continuation)
|
||||
}
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
os_log(.debug, log: log, "Completed ingesting items from %{public}@", resource.id)
|
||||
didFinish()
|
||||
}
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
operationQueue.cancelAllOperations()
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// FeedlyUpdateAccountFeedsWithItemsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
|
||||
/// Combine the articles with their feeds for a specific account.
|
||||
final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation {
|
||||
|
||||
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 run() {
|
||||
let feedIDsAndItems = organisedItemsProvider.parsedItemsKeyedByFeedId
|
||||
|
||||
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true) { databaseError in
|
||||
if let error = databaseError {
|
||||
self.didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Updated %i feeds for \"%@\"", feedIDsAndItems.count, self.organisedItemsProvider.parsedItemsByFeedProviderName)
|
||||
self.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// FeedlyGetCollectionsService.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 21/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetCollectionsService: AnyObject {
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// FeedlyGetEntriesService.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 28/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetEntriesService: AnyObject {
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> ())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// FeedlyGetStreamContentsService.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 21/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetStreamContentsService: AnyObject {
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user