Move modules to Modules folder.

This commit is contained in:
Brent Simmons
2025-01-06 21:13:56 -08:00
parent 430871c94a
commit 2933d9aca0
463 changed files with 2 additions and 20 deletions

5
Modules/Account/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View File

@@ -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>

View 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"),
]),
]
)

View File

@@ -0,0 +1,3 @@
# Account
A description of this package.

File diff suppressed because it is too large Load Diff

View 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)
}

View 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 accounts 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()
}

View 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 couldnt be found and cant be added.", comment: "Not found")
case .createErrorAlreadySubscribed:
return NSLocalizedString("You are already subscribed to this feed and cant 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
}
}

View 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)
}
}

View 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)
}
}

View 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()
}
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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")!
}

View 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)
}
}

View 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)
}
}

View 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)
}
}
}

View 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)
}
}

View 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)
}
}

View 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 dont 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 dont 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? {
// Dont 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
// Dont 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
})
}
}

View 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 theres an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesnt, 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 its a WordPress site, and just adding /feed/ will work.
// Its 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)
}
}

View 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
}
}

View File

@@ -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 theres an existing feed specifier, merge the two so that we have the best data. If one has a title and one doesnt, 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
}
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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
}()
}

View 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"
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -0,0 +1,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"
}
}

View 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
}
}

View 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"
}
}

View File

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

View File

@@ -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))
}
}
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 articles 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 authors 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 feeds 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]?
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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?
}

View File

@@ -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
}
}

View File

@@ -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]
}

View File

@@ -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?
}

View File

@@ -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?
}

View File

@@ -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])
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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?
}

View File

@@ -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()
}
}

View File

@@ -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>) -> ())
}

View File

@@ -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")
}
}

View File

@@ -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>) -> ())
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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. Theres 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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 })
}
}
}

View File

@@ -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 dont do inheritance but in this case
/// its 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()
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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>) -> ())
}

View File

@@ -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>) -> ())
}

View File

@@ -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