mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Add ReaderAPI and AccountError packages.
This commit is contained in:
8
Account/File.swift
Normal file
8
Account/File.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// File.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 8/27/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -10,11 +10,13 @@ var dependencies: [Package.Dependency] = [
|
||||
|
||||
#if swift(>=5.6)
|
||||
dependencies.append(contentsOf: [
|
||||
.package(path: "../AccountError"),
|
||||
.package(path: "../Articles"),
|
||||
.package(path: "../ArticlesDatabase"),
|
||||
.package(path: "../Secrets"),
|
||||
.package(path: "../SyncDatabase"),
|
||||
.package(path: "../SyncClients/NewsBlur"),
|
||||
.package(path: "../SyncClients/ReaderAPI"),
|
||||
])
|
||||
#else
|
||||
dependencies.append(contentsOf: [
|
||||
@@ -44,11 +46,13 @@ let package = Package(
|
||||
"RSDatabase",
|
||||
"RSParser",
|
||||
"RSWeb",
|
||||
"AccountError",
|
||||
"Articles",
|
||||
"ArticlesDatabase",
|
||||
"Secrets",
|
||||
"SyncDatabase",
|
||||
"NewsBlur"
|
||||
"NewsBlur",
|
||||
"ReaderAPI"
|
||||
],
|
||||
linkerSettings: [
|
||||
.unsafeFlags(["-Xlinker", "-no_application_extension"])
|
||||
|
||||
@@ -11,6 +11,7 @@ import UIKit
|
||||
#endif
|
||||
|
||||
import Foundation
|
||||
import AccountError
|
||||
import RSCore
|
||||
import Articles
|
||||
import RSParser
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AccountError
|
||||
import CloudKit
|
||||
import SystemConfiguration
|
||||
import SyncDatabase
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import AccountError
|
||||
|
||||
class FeedFinder {
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Articles
|
||||
import AccountError
|
||||
import RSCore
|
||||
import RSDatabase
|
||||
import RSParser
|
||||
@@ -92,7 +93,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.refreshProgress.clear()
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -101,7 +102,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.refreshProgress.clear()
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -276,7 +277,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
self.refreshProgress.completeTask()
|
||||
self.isOPMLImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -311,7 +312,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -405,7 +406,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -436,7 +437,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
continuation.resume(throwing: wrappedError)
|
||||
}
|
||||
}
|
||||
@@ -464,7 +465,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -511,7 +512,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -1376,7 +1377,7 @@ private extension FeedbinAccountDelegate {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import RSParser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
import AccountError
|
||||
|
||||
final class FeedlyAccountDelegate: AccountDelegate, Logging {
|
||||
|
||||
@@ -236,7 +237,7 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging {
|
||||
self.refreshProgress.completeTask()
|
||||
self.isOPMLImportInProgress = false
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AccountError
|
||||
|
||||
protocol FeedlyAddFeedToCollectionService {
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ())
|
||||
|
||||
@@ -11,6 +11,7 @@ import SyncDatabase
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
import AccountError
|
||||
|
||||
class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate, Logging {
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import Articles
|
||||
import ArticlesDatabase
|
||||
import RSWeb
|
||||
import Secrets
|
||||
import AccountError
|
||||
|
||||
public enum LocalAccountDelegateError: String, Error {
|
||||
case invalidParameter = "An invalid parameter was used."
|
||||
|
||||
@@ -14,6 +14,7 @@ import RSParser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import NewsBlur
|
||||
import AccountError
|
||||
|
||||
extension NewsBlurAccountDelegate {
|
||||
|
||||
@@ -527,7 +528,7 @@ extension NewsBlurAccountDelegate {
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import RSWeb
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
import NewsBlur
|
||||
import AccountError
|
||||
|
||||
final class NewsBlurAccountDelegate: AccountDelegate, Logging {
|
||||
|
||||
@@ -95,7 +96,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging {
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.refreshProgress.clear()
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -437,7 +438,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging {
|
||||
self.createFeed(account: account, newsBlurFeed: feed, name: name, container: container, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -463,7 +464,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging {
|
||||
continuation.resume()
|
||||
|
||||
case .failure(let error):
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
continuation.resume(throwing: wrappedError)
|
||||
}
|
||||
}
|
||||
@@ -491,7 +492,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging {
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import RSParser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
import ReaderAPI
|
||||
import AccountError
|
||||
|
||||
public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
case unknown
|
||||
@@ -34,7 +36,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
|
||||
@MainActor final class ReaderAPIAccountDelegate: AccountDelegate, Logging {
|
||||
|
||||
|
||||
private let variant: ReaderAPIVariant
|
||||
|
||||
private let database: SyncDatabase
|
||||
@@ -63,20 +65,18 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
weak var accountMetadata: AccountMetadata? {
|
||||
didSet {
|
||||
caller.accountMetadata = accountMetadata
|
||||
}
|
||||
}
|
||||
|
||||
private weak var account: Account?
|
||||
weak var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String, transport: Transport?, variant: ReaderAPIVariant) {
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
|
||||
self.variant = variant
|
||||
|
||||
if transport != nil {
|
||||
caller = ReaderAPICaller(transport: transport!)
|
||||
self.caller = ReaderAPICaller(transport: transport!)
|
||||
} else {
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
@@ -91,11 +91,11 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration))
|
||||
self.caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration))
|
||||
}
|
||||
|
||||
caller.variant = variant
|
||||
self.variant = variant
|
||||
|
||||
self.caller.delegate = self
|
||||
self.caller.variant = variant
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
@@ -135,7 +135,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
DispatchQueue.main.async {
|
||||
self.refreshProgress.clear()
|
||||
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL {
|
||||
self.caller.credentials = basicCredentials
|
||||
|
||||
@@ -311,7 +311,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
} else {
|
||||
self.caller.deleteTag(folder: folder) { result in
|
||||
self.caller.deleteTag(folderExternalID: folder.externalID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder)
|
||||
@@ -407,7 +407,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
return
|
||||
}
|
||||
|
||||
self.caller.createSubscription(url: bestFeedSpecifier.urlString, name: name, folder: container as? Folder) { result in
|
||||
self.caller.createSubscription(url: bestFeedSpecifier.urlString, name: name) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let subResult):
|
||||
@@ -421,7 +421,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -456,7 +456,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
continuation.resume()
|
||||
}
|
||||
case .failure(let error):
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
continuation.resume(throwing: wrappedError)
|
||||
}
|
||||
}
|
||||
@@ -484,7 +484,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -515,7 +515,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -565,7 +565,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
}
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = WrappedAccountError(account: account, underlyingError: error)
|
||||
let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error)
|
||||
completion(.failure(wrappedError))
|
||||
}
|
||||
}
|
||||
@@ -665,21 +665,20 @@ public enum ReaderAPIAccountDelegateError: LocalizedError {
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
}
|
||||
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
guard let endpoint = endpoint else {
|
||||
completion(.failure(TransportError.noURL))
|
||||
return
|
||||
}
|
||||
|
||||
let caller = ReaderAPICaller(transport: transport)
|
||||
caller.credentials = credentials
|
||||
caller.validateCredentials(endpoint: endpoint) { result in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
ReaderAPICaller.validateCredentials(credentials: credentials, transport: transport, endpoint: endpoint, variant: .generic) { url, credentials in
|
||||
URLRequest(url: url, credentials: credentials)
|
||||
} completion: { result in
|
||||
Task { @MainActor in
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
@@ -1074,10 +1073,14 @@ private extension ReaderAPIAccountDelegate {
|
||||
guard let entries = entries else {
|
||||
return Set<ParsedItem>()
|
||||
}
|
||||
|
||||
let parsedItems: [ParsedItem] = entries.compactMap { entry in
|
||||
|
||||
// There was a compactMap here, but somehow the compiler got confused about returning nil
|
||||
// (which is kind of the point of compactMap) so we’re doing things the old-fashioned way.
|
||||
// Hope the compiler is happy.
|
||||
var parsedItems = Set<ParsedItem>()
|
||||
for entry in entries {
|
||||
guard let streamID = entry.origin.streamId else {
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
|
||||
var authors: Set<ParsedAuthor>? {
|
||||
@@ -1086,8 +1089,8 @@ private extension ReaderAPIAccountDelegate {
|
||||
}
|
||||
return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
}
|
||||
|
||||
return ParsedItem(syncServiceID: entry.uniqueID(variant: variant),
|
||||
|
||||
let parsedItem = ParsedItem(syncServiceID: entry.uniqueID(variant: variant),
|
||||
uniqueID: entry.uniqueID(variant: variant),
|
||||
feedURL: streamID,
|
||||
url: nil,
|
||||
@@ -1104,9 +1107,10 @@ private extension ReaderAPIAccountDelegate {
|
||||
authors: authors,
|
||||
tags: nil,
|
||||
attachments: nil)
|
||||
parsedItems.insert(parsedItem)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
|
||||
return parsedItems
|
||||
|
||||
}
|
||||
|
||||
@@ -1170,3 +1174,37 @@ private extension ReaderAPIAccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReaderAPIAccountDelegate: ReaderAPICallerDelegate {
|
||||
|
||||
var apiBaseURL: URL? {
|
||||
switch variant {
|
||||
case .generic, .freshRSS:
|
||||
return accountMetadata?.endpointURL
|
||||
default:
|
||||
return URL(string: variant.host)
|
||||
}
|
||||
}
|
||||
|
||||
var lastArticleFetchStartTime: Date? {
|
||||
get {
|
||||
account?.metadata.lastArticleFetchStartTime
|
||||
}
|
||||
set {
|
||||
account?.metadata.lastArticleFetchStartTime = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var lastArticleFetchEndTime: Date? {
|
||||
get {
|
||||
account?.metadata.lastArticleFetchEndTime
|
||||
}
|
||||
set {
|
||||
account?.metadata.lastArticleFetchEndTime = newValue
|
||||
}
|
||||
}
|
||||
|
||||
func createURLRequest(url: URL, credentials: Secrets.Credentials?) -> URLRequest {
|
||||
URLRequest(url: url, credentials: credentials)
|
||||
}
|
||||
}
|
||||
|
||||
9
AccountError/.gitignore
vendored
Normal file
9
AccountError/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
14
AccountError/Package.resolved
Normal file
14
AccountError/Package.resolved
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "rsweb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSWeb.git",
|
||||
"state" : {
|
||||
"revision" : "aca2db763e3404757b273821f058bed2bbe02fcf",
|
||||
"version" : "1.0.7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
31
AccountError/Package.swift
Normal file
31
AccountError/Package.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AccountError",
|
||||
defaultLocalization: "en",
|
||||
platforms: [.macOS(.v13), .iOS(.v16)],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "AccountError",
|
||||
targets: ["AccountError"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "AccountError",
|
||||
dependencies: [
|
||||
"RSWeb"
|
||||
]),
|
||||
.testTarget(
|
||||
name: "AccountErrorTests",
|
||||
dependencies: ["AccountError"]),
|
||||
]
|
||||
)
|
||||
3
AccountError/README.md
Normal file
3
AccountError/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# AccountError
|
||||
|
||||
A description of this package.
|
||||
@@ -38,10 +38,10 @@ public struct WrappedAccountError: LocalizedError {
|
||||
|
||||
private let accountNameForDisplay: String
|
||||
|
||||
@MainActor init(account: Account, underlyingError: Error) {
|
||||
self.accountID = account.accountID
|
||||
@MainActor public init(accountID: String, accountNameForDisplay: String, underlyingError: Error) {
|
||||
self.accountID = accountID
|
||||
self.accountNameForDisplay = accountNameForDisplay
|
||||
self.underlyingError = underlyingError
|
||||
self.accountNameForDisplay = account.nameForDisplay
|
||||
|
||||
var isCredentialsError = false
|
||||
if case TransportError.httpError(let status) = underlyingError {
|
||||
@@ -0,0 +1,2 @@
|
||||
import XCTest
|
||||
@testable import AccountError
|
||||
@@ -13,6 +13,7 @@ import RSTree
|
||||
import Articles
|
||||
import Account
|
||||
import RSParser
|
||||
import AccountError
|
||||
|
||||
// Run add-feed sheet.
|
||||
// If it returns with URL and optional name,
|
||||
|
||||
@@ -11,6 +11,7 @@ import Account
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
import ReaderAPI
|
||||
|
||||
@MainActor class AccountsReaderAPIWindowController: NSWindowController, Logging {
|
||||
|
||||
|
||||
@@ -1380,6 +1380,8 @@
|
||||
8483630A2262A3F000DA1D35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Mac/Base.lproj/RenameSheet.xib; sourceTree = SOURCE_ROOT; };
|
||||
8483630D2262A3FE00DA1D35 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Mac/Base.lproj/MainWindow.storyboard; sourceTree = SOURCE_ROOT; };
|
||||
8486EC3E2A9BE083007EF90D /* NewsBlur */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NewsBlur; path = SyncClients/NewsBlur; sourceTree = "<group>"; };
|
||||
8486EC3F2A9C2431007EF90D /* ReaderAPI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = ReaderAPI; path = SyncClients/ReaderAPI; sourceTree = "<group>"; };
|
||||
8486EC402A9C2EFE007EF90D /* AccountError */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AccountError; sourceTree = "<group>"; };
|
||||
848B937121C8C5540038DC0D /* CrashReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReporter.swift; sourceTree = "<group>"; };
|
||||
848D578D21543519005FFAD5 /* PasteboardFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardFeed.swift; sourceTree = "<group>"; };
|
||||
848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconDownloader.swift; sourceTree = "<group>"; };
|
||||
@@ -2502,6 +2504,8 @@
|
||||
849C64611ED37A5D003D8FC0 /* Products */,
|
||||
51C452B22265141B00C03939 /* Frameworks */,
|
||||
51CD32C624D2DEF9009ABAEF /* Account */,
|
||||
8486EC402A9C2EFE007EF90D /* AccountError */,
|
||||
8486EC3F2A9C2431007EF90D /* ReaderAPI */,
|
||||
8486EC3E2A9BE083007EF90D /* NewsBlur */,
|
||||
51CD32C424D2CF1D009ABAEF /* Articles */,
|
||||
51CD32C324D2CD57009ABAEF /* ArticlesDatabase */,
|
||||
|
||||
32
SyncClients/NewsBlur/Package.resolved
Normal file
32
SyncClients/NewsBlur/Package.resolved
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "rscore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSCore.git",
|
||||
"state" : {
|
||||
"revision" : "55644a3a037fed14f22ee2c0b531808f95051708",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "rsparser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSParser.git",
|
||||
"state" : {
|
||||
"revision" : "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "rsweb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSWeb.git",
|
||||
"state" : {
|
||||
"revision" : "aca2db763e3404757b273821f058bed2bbe02fcf",
|
||||
"version" : "1.0.7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
targets: ["NewsBlur"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Secrets"),
|
||||
.package(path: "../../Secrets"),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2"))
|
||||
|
||||
9
SyncClients/ReaderAPI/.gitignore
vendored
Normal file
9
SyncClients/ReaderAPI/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
8
SyncClients/ReaderAPI/File.swift
Normal file
8
SyncClients/ReaderAPI/File.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// File.swift
|
||||
// ReaderAPI
|
||||
//
|
||||
// Created by Brent Simmons on 8/27/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
32
SyncClients/ReaderAPI/Package.resolved
Normal file
32
SyncClients/ReaderAPI/Package.resolved
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "rscore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSCore.git",
|
||||
"state" : {
|
||||
"revision" : "55644a3a037fed14f22ee2c0b531808f95051708",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "rsparser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSParser.git",
|
||||
"state" : {
|
||||
"revision" : "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "rsweb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Ranchero-Software/RSWeb.git",
|
||||
"state" : {
|
||||
"revision" : "aca2db763e3404757b273821f058bed2bbe02fcf",
|
||||
"version" : "1.0.7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
38
SyncClients/ReaderAPI/Package.swift
Normal file
38
SyncClients/ReaderAPI/Package.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ReaderAPI",
|
||||
platforms: [.macOS(.v13), .iOS(.v16)],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "ReaderAPI",
|
||||
targets: ["ReaderAPI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../../Secrets"),
|
||||
.package(path: "../../AccountError"),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")),
|
||||
.package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2"))
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "ReaderAPI",
|
||||
dependencies: [
|
||||
"AccountError",
|
||||
"Secrets",
|
||||
"RSWeb",
|
||||
"RSParser",
|
||||
"RSCore"
|
||||
]),
|
||||
// .testTarget(
|
||||
// name: "ReaderAPITests",
|
||||
// dependencies: ["ReaderAPI"]),
|
||||
]
|
||||
)
|
||||
3
SyncClients/ReaderAPI/README.md
Normal file
3
SyncClients/ReaderAPI/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ReaderAPI
|
||||
|
||||
A description of this package.
|
||||
6
SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift
Normal file
6
SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPI.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
public struct ReaderAPI {
|
||||
public private(set) var text = "Hello, World!"
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,29 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AccountError
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
enum CreateReaderAPISubscriptionResult {
|
||||
public enum CreateReaderAPISubscriptionResult {
|
||||
case created(ReaderAPISubscription)
|
||||
case notFound
|
||||
}
|
||||
|
||||
final class ReaderAPICaller: NSObject {
|
||||
public protocol ReaderAPICallerDelegate: AnyObject {
|
||||
|
||||
var apiBaseURL: URL? { get }
|
||||
var lastArticleFetchStartTime: Date? { get set }
|
||||
var lastArticleFetchEndTime: Date? { get set }
|
||||
|
||||
func createURLRequest(url: URL, credentials: Credentials?) -> URLRequest
|
||||
}
|
||||
|
||||
struct MissingDelegateError: Error {}
|
||||
|
||||
public final class ReaderAPICaller {
|
||||
|
||||
enum ItemIDType {
|
||||
public enum ItemIDType {
|
||||
case unread
|
||||
case starred
|
||||
case allForAccount
|
||||
@@ -47,17 +59,18 @@ final class ReaderAPICaller: NSObject {
|
||||
case editTag = "/reader/api/0/edit-tag"
|
||||
}
|
||||
|
||||
public weak var delegate: ReaderAPICallerDelegate?
|
||||
|
||||
private var transport: Transport!
|
||||
private let missingDelegateError = MissingDelegateError()
|
||||
private let uriComponentAllowed: CharacterSet
|
||||
|
||||
private var accessToken: String?
|
||||
|
||||
weak var accountMetadata: AccountMetadata?
|
||||
public var variant: ReaderAPIVariant = .generic
|
||||
public var credentials: Credentials?
|
||||
|
||||
var variant: ReaderAPIVariant = .generic
|
||||
var credentials: Credentials?
|
||||
|
||||
var server: String? {
|
||||
public var server: String? {
|
||||
get {
|
||||
return apiBaseURL?.host
|
||||
}
|
||||
@@ -65,40 +78,27 @@ final class ReaderAPICaller: NSObject {
|
||||
|
||||
private var apiBaseURL: URL? {
|
||||
get {
|
||||
switch variant {
|
||||
case .generic, .freshRSS:
|
||||
guard let accountMetadata = accountMetadata else {
|
||||
return nil
|
||||
}
|
||||
return accountMetadata.endpointURL
|
||||
default:
|
||||
return URL(string: variant.host)
|
||||
}
|
||||
delegate!.apiBaseURL
|
||||
}
|
||||
}
|
||||
|
||||
init(transport: Transport) {
|
||||
public init(transport: Transport) {
|
||||
self.transport = transport
|
||||
|
||||
|
||||
var urlHostAllowed = CharacterSet.urlHostAllowed
|
||||
urlHostAllowed.remove("+")
|
||||
urlHostAllowed.remove("&")
|
||||
uriComponentAllowed = urlHostAllowed
|
||||
super.init()
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
public func cancelAll() {
|
||||
transport.cancelAll()
|
||||
}
|
||||
|
||||
func validateCredentials(endpoint: URL, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
guard let credentials = credentials else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
public static func validateCredentials(credentials: Credentials, transport: Transport, endpoint: URL, variant: ReaderAPIVariant, createURLRequest: (URL, Credentials?) -> URLRequest, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
|
||||
var request = createURLRequest(endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials)
|
||||
addVariantHeaders(&request, variant)
|
||||
|
||||
transport.send(request: request) { result in
|
||||
switch result {
|
||||
@@ -107,13 +107,13 @@ final class ReaderAPICaller: NSObject {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
// Convert the return data to UTF8 and then parse out the Auth token
|
||||
guard let rawData = String(data: resultData, encoding: .utf8) else {
|
||||
completion(.failure(TransportError.noData))
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
var authData: [String: String] = [:]
|
||||
rawData.split(separator: "\n").forEach({ (line: Substring) in
|
||||
let items = line.split(separator: "=").map{String($0)}
|
||||
@@ -121,28 +121,56 @@ final class ReaderAPICaller: NSObject {
|
||||
authData[items[0]] = items[1]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
guard let authString = authData["Auth"] else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
break
|
||||
}
|
||||
|
||||
// Save Auth Token for later use
|
||||
self.credentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString)
|
||||
|
||||
completion(.success(self.credentials))
|
||||
|
||||
let validatedCredentials = Credentials(type: .readerAPIKey, username: credentials.username, secret: authString)
|
||||
completion(.success(validatedCredentials))
|
||||
case .failure(let error):
|
||||
if let transportError = error as? TransportError, case .httpError(let code) = transportError, code == 404 {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.urlNotFound))
|
||||
completion(.failure(ReaderAPIError.urlNotFound))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func validateCredentials(endpoint: URL, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
guard let credentials else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
}
|
||||
|
||||
Self.validateCredentials(credentials: credentials, transport: transport, endpoint: endpoint, variant: variant, createURLRequest: delegate.createURLRequest) { result in
|
||||
|
||||
switch result {
|
||||
|
||||
case .success(let validatedCredentials):
|
||||
// Save Auth Token for later use
|
||||
if let validatedCredentials {
|
||||
self.credentials = validatedCredentials
|
||||
}
|
||||
completion(.success(validatedCredentials))
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorizationToken(endpoint: URL, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
// If we have a token already, use it
|
||||
if let accessToken = accessToken {
|
||||
completion(.success(accessToken))
|
||||
@@ -155,7 +183,7 @@ final class ReaderAPICaller: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials)
|
||||
var request = delegate.createURLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
transport.send(request: request) { result in
|
||||
@@ -181,7 +209,11 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
|
||||
|
||||
func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) {
|
||||
public func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) {
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -200,7 +232,7 @@ final class ReaderAPICaller: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: callURL, credentials: credentials)
|
||||
var request = delegate.createURLRequest(url: callURL, credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
transport.send(request: request, resultType: ReaderAPITagContainer.self) { result in
|
||||
@@ -214,7 +246,7 @@ final class ReaderAPICaller: NSObject {
|
||||
|
||||
}
|
||||
|
||||
func renameTag(oldName: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func renameTag(oldName: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -223,13 +255,17 @@ final class ReaderAPICaller: NSObject {
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
guard let encodedOldName = self.encodeForURLPath(oldName), let encodedNewName = self.encodeForURLPath(newName) else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,21 +291,25 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func deleteTag(folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
@MainActor public func deleteTag(folderExternalID: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
}
|
||||
|
||||
guard let folderExternalID = folder.externalID else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
guard let folderExternalID else {
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
@@ -294,7 +334,11 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) {
|
||||
public func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) {
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -309,7 +353,7 @@ final class ReaderAPICaller: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: callURL, credentials: credentials)
|
||||
var request = delegate.createURLRequest(url: callURL, credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) { result in
|
||||
@@ -322,7 +366,7 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func createSubscription(url: String, name: String?, folder: Folder?, completion: @escaping (Result<CreateReaderAPISubscriptionResult, Error>) -> Void) {
|
||||
public func createSubscription(url: String, name: String?, completion: @escaping (Result<CreateReaderAPISubscriptionResult, Error>) -> Void) {
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -358,16 +402,20 @@ final class ReaderAPICaller: NSObject {
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
let callURL = baseURL
|
||||
.appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue)
|
||||
|
||||
var request = URLRequest(url: callURL, credentials: self.credentials)
|
||||
var request = delegate.createURLRequest(url: callURL, credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
guard let encodedFeedURL = self.encodeForURLPath(url) else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
let postData = "T=\(token)&quickadd=\(encodedFeedURL)".data(using: String.Encoding.utf8)
|
||||
@@ -402,11 +450,11 @@ final class ReaderAPICaller: NSObject {
|
||||
|
||||
}
|
||||
|
||||
func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
changeSubscription(subscriptionID: subscriptionID, title: newName, completion: completion)
|
||||
}
|
||||
|
||||
func deleteSubscription(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func deleteSubscription(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -415,7 +463,11 @@ final class ReaderAPICaller: NSObject {
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
@@ -439,21 +491,21 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
changeSubscription(subscriptionID: subscriptionID, addTagName: tagName, completion: completion)
|
||||
}
|
||||
|
||||
func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
changeSubscription(subscriptionID: subscriptionID, removeTagName: tagName, completion: completion)
|
||||
}
|
||||
|
||||
func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
changeSubscription(subscriptionID: subscriptionID, removeTagName: fromTag, addTagName: toTag, completion: completion)
|
||||
}
|
||||
|
||||
private func changeSubscription(subscriptionID: String, removeTagName: String? = nil, addTagName: String? = nil, title: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard removeTagName != nil || addTagName != nil || title != nil else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -465,7 +517,11 @@ final class ReaderAPICaller: NSObject {
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
@@ -499,7 +555,7 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) {
|
||||
public func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) {
|
||||
|
||||
guard !articleIDs.isEmpty else {
|
||||
completion(.success(([ReaderAPIEntry]())))
|
||||
@@ -514,7 +570,11 @@ final class ReaderAPICaller: NSObject {
|
||||
self.requestAuthorizationToken(endpoint: baseURL) { (result) in
|
||||
switch result {
|
||||
case .success(let token):
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
@@ -536,7 +596,7 @@ final class ReaderAPICaller: NSObject {
|
||||
switch result {
|
||||
case .success(let (_, entryWrapper)):
|
||||
guard let entryWrapper = entryWrapper else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidResponse))
|
||||
completion(.failure(ReaderAPIError.invalidResponse))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -554,7 +614,11 @@ final class ReaderAPICaller: NSObject {
|
||||
|
||||
}
|
||||
|
||||
func retrieveItemIDs(type: ItemIDType, feedID: String? = nil, completion: @escaping ((Result<[String], Error>) -> Void)) {
|
||||
public func retrieveItemIDs(type: ItemIDType, feedID: String? = nil, completion: @escaping ((Result<[String], Error>) -> Void)) {
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
return
|
||||
@@ -568,7 +632,7 @@ final class ReaderAPICaller: NSObject {
|
||||
switch type {
|
||||
case .allForAccount:
|
||||
let since: Date = {
|
||||
if let lastArticleFetch = self.accountMetadata?.lastArticleFetchStartTime {
|
||||
if let lastArticleFetch = delegate.lastArticleFetchStartTime {
|
||||
return lastArticleFetch
|
||||
} else {
|
||||
return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
@@ -580,7 +644,7 @@ final class ReaderAPICaller: NSObject {
|
||||
queryItems.append(URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue))
|
||||
case .allForFeed:
|
||||
guard let feedID = feedID else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
let sinceTimeInterval = (Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()).timeIntervalSince1970
|
||||
@@ -602,7 +666,7 @@ final class ReaderAPICaller: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
var request: URLRequest = URLRequest(url: callURL, credentials: credentials)
|
||||
var request = delegate.createURLRequest(url: callURL, credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in
|
||||
@@ -624,15 +688,15 @@ final class ReaderAPICaller: NSObject {
|
||||
func retrieveItemIDs(type: ItemIDType, url: URL, dateInfo: HTTPDateInfo?, itemIDs: [String], continuation: String?, completion: @escaping ((Result<[String], Error>) -> Void)) {
|
||||
guard let continuation = continuation else {
|
||||
if type == .allForAccount {
|
||||
self.accountMetadata?.lastArticleFetchStartTime = dateInfo?.date
|
||||
self.accountMetadata?.lastArticleFetchEndTime = Date()
|
||||
self.delegate?.lastArticleFetchStartTime = dateInfo?.date
|
||||
self.delegate?.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
completion(.success(itemIDs))
|
||||
return
|
||||
}
|
||||
|
||||
guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
completion(.failure(ReaderAPIAccountDelegateError.invalidParameter))
|
||||
completion(.failure(ReaderAPIError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -644,8 +708,11 @@ final class ReaderAPICaller: NSObject {
|
||||
completion(.failure(TransportError.noURL))
|
||||
return
|
||||
}
|
||||
|
||||
var request: URLRequest = URLRequest(url: callURL, credentials: credentials)
|
||||
guard let delegate else {
|
||||
completion(.failure(missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: callURL, credentials: credentials)
|
||||
addVariantHeaders(&request)
|
||||
|
||||
self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in
|
||||
@@ -664,19 +731,19 @@ final class ReaderAPICaller: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func createUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func createUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
updateStateToEntries(entries: entries, state: .read, add: false, completion: completion)
|
||||
}
|
||||
|
||||
func deleteUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func deleteUnreadEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
updateStateToEntries(entries: entries, state: .read, add: true, completion: completion)
|
||||
}
|
||||
|
||||
func createStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func createStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
updateStateToEntries(entries: entries, state: .starred, add: true, completion: completion)
|
||||
}
|
||||
|
||||
func deleteStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func deleteStarredEntries(entries: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
updateStateToEntries(entries: entries, state: .starred, add: false, completion: completion)
|
||||
}
|
||||
|
||||
@@ -691,13 +758,17 @@ private extension ReaderAPICaller {
|
||||
return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
|
||||
}
|
||||
|
||||
func addVariantHeaders(_ request: inout URLRequest) {
|
||||
static func addVariantHeaders(_ request: inout URLRequest, _ variant: ReaderAPIVariant) {
|
||||
if variant == .inoreader {
|
||||
request.addValue(SecretsManager.provider.inoreaderAppId, forHTTPHeaderField: "AppId")
|
||||
request.addValue(SecretsManager.provider.inoreaderAppKey, forHTTPHeaderField: "AppKey")
|
||||
}
|
||||
}
|
||||
|
||||
func addVariantHeaders(_ request: inout URLRequest) {
|
||||
Self.addVariantHeaders(&request, variant)
|
||||
}
|
||||
|
||||
private func updateStateToEntries(entries: [String], state: ReaderState, add: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let baseURL = apiBaseURL else {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@@ -708,7 +779,11 @@ private extension ReaderAPICaller {
|
||||
switch result {
|
||||
case .success(let token):
|
||||
// Do POST asking for data about all the new articles
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials)
|
||||
guard let delegate = self.delegate else {
|
||||
completion(.failure(self.missingDelegateError))
|
||||
return
|
||||
}
|
||||
var request = delegate.createURLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
@@ -47,20 +47,20 @@ struct ReaderAPIEntryWrapper: Codable {
|
||||
}
|
||||
}
|
||||
*/
|
||||
struct ReaderAPIEntry: Codable {
|
||||
public struct ReaderAPIEntry: Codable {
|
||||
|
||||
let articleID: String
|
||||
let title: String?
|
||||
let author: String?
|
||||
public let title: String?
|
||||
public let author: String?
|
||||
|
||||
let publishedTimestamp: Double?
|
||||
let crawledTimestamp: String?
|
||||
let timestampUsec: String?
|
||||
|
||||
let summary: ReaderAPIArticleSummary
|
||||
let alternates: [ReaderAPIAlternateLocation]?
|
||||
public let summary: ReaderAPIArticleSummary
|
||||
public let alternates: [ReaderAPIAlternateLocation]?
|
||||
let categories: [String]
|
||||
let origin: ReaderAPIEntryOrigin
|
||||
public let origin: ReaderAPIEntryOrigin
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case articleID = "id"
|
||||
@@ -75,14 +75,14 @@ struct ReaderAPIEntry: Codable {
|
||||
case timestampUsec = "timestampUsec"
|
||||
}
|
||||
|
||||
func parseDatePublished() -> Date? {
|
||||
public func parseDatePublished() -> Date? {
|
||||
guard let unixTime = publishedTimestamp else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: unixTime)
|
||||
}
|
||||
|
||||
func uniqueID(variant: ReaderAPIVariant) -> String {
|
||||
public func uniqueID(variant: ReaderAPIVariant) -> String {
|
||||
// Should look something like "tag:google.com,2005:reader/item/00058b10ce338909"
|
||||
// REGEX feels heavy, I should be able to just split on / and take the last element
|
||||
|
||||
@@ -104,24 +104,24 @@ struct ReaderAPIEntry: Codable {
|
||||
|
||||
}
|
||||
|
||||
struct ReaderAPIArticleSummary: Codable {
|
||||
let content: String?
|
||||
public struct ReaderAPIArticleSummary: Codable {
|
||||
public let content: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case content = "content"
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderAPIAlternateLocation: Codable {
|
||||
let url: String?
|
||||
public struct ReaderAPIAlternateLocation: Codable {
|
||||
public let url: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url = "href"
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderAPIEntryOrigin: Codable {
|
||||
let streamId: String?
|
||||
public struct ReaderAPIEntryOrigin: Codable {
|
||||
public let streamId: String?
|
||||
let title: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
28
SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift
Normal file
28
SyncClients/ReaderAPI/Sources/ReaderAPI/ReaderAPIError.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// ReaderAPIError.swift
|
||||
//
|
||||
//
|
||||
// Created by Jeremy Beker on 5/28/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ReaderAPIError: LocalizedError {
|
||||
case unknown
|
||||
case invalidParameter
|
||||
case invalidResponse
|
||||
case urlNotFound
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unknown:
|
||||
return NSLocalizedString("An unexpected error occurred.", comment: "An unexpected error occurred.")
|
||||
case .invalidParameter:
|
||||
return NSLocalizedString("An invalid parameter was passed.", comment: "An invalid parameter was passed.")
|
||||
case .invalidResponse:
|
||||
return NSLocalizedString("There was an invalid response from the server.", comment: "There was an invalid response from the server.")
|
||||
case .urlNotFound:
|
||||
return NSLocalizedString("The API URL wasn't found.", comment: "The API URL wasn't found.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,12 @@ struct ReaderAPISubscriptionContainer: Codable {
|
||||
}
|
||||
|
||||
*/
|
||||
struct ReaderAPISubscription: Codable {
|
||||
let feedID: String
|
||||
let name: String?
|
||||
let categories: [ReaderAPICategory]
|
||||
public struct ReaderAPISubscription: Codable {
|
||||
public let feedID: String
|
||||
public let name: String?
|
||||
public let categories: [ReaderAPICategory]
|
||||
let feedURL: String?
|
||||
let homePageURL: String?
|
||||
public let homePageURL: String?
|
||||
let iconURL: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -72,7 +72,7 @@ struct ReaderAPISubscription: Codable {
|
||||
case iconURL = "iconUrl"
|
||||
}
|
||||
|
||||
var url: String {
|
||||
public var url: String {
|
||||
if let feedURL = feedURL {
|
||||
return feedURL
|
||||
} else {
|
||||
@@ -81,8 +81,8 @@ struct ReaderAPISubscription: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderAPICategory: Codable {
|
||||
let categoryId: String
|
||||
public struct ReaderAPICategory: Codable {
|
||||
public let categoryId: String
|
||||
let categoryLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -16,17 +16,17 @@ struct ReaderAPITagContainer: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderAPITag: Codable {
|
||||
public struct ReaderAPITag: Codable {
|
||||
|
||||
let tagID: String
|
||||
let type: String?
|
||||
public let tagID: String
|
||||
public let type: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case tagID = "id"
|
||||
case type = "type"
|
||||
}
|
||||
|
||||
var folderName: String? {
|
||||
public var folderName: String? {
|
||||
guard let range = tagID.range(of: "/label/") else {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import XCTest
|
||||
@testable import ReaderAPI
|
||||
|
||||
final class ReaderAPITests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(ReaderAPI().text, "Hello, World!")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user