mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move local modules into a folder named Modules.
This commit is contained in:
5
Modules/Account/.gitignore
vendored
Normal file
5
Modules/Account/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1530"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "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">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
67
Modules/Account/Package.swift
Normal file
67
Modules/Account/Package.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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: "../Parser"),
|
||||
.package(path: "../ParserObjC"),
|
||||
.package(path: "../Articles"),
|
||||
.package(path: "../ArticlesDatabase"),
|
||||
.package(path: "../Web"),
|
||||
.package(path: "../Secrets"),
|
||||
.package(path: "../Database"),
|
||||
.package(path: "../SyncDatabase"),
|
||||
.package(path: "../Core"),
|
||||
.package(path: "../ReaderAPI"),
|
||||
.package(path: "../CloudKitSync"),
|
||||
.package(path: "../NewsBlur"),
|
||||
.package(path: "../Feedbin"),
|
||||
.package(path: "../LocalAccount"),
|
||||
.package(path: "../FeedFinder"),
|
||||
.package(path: "../Feedly"),
|
||||
.package(path: "../CommonErrors"),
|
||||
.package(path: "../FeedDownloader")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Account",
|
||||
dependencies: [
|
||||
"Parser",
|
||||
"ParserObjC",
|
||||
"Web",
|
||||
"Articles",
|
||||
"ArticlesDatabase",
|
||||
"Secrets",
|
||||
"SyncDatabase",
|
||||
"Database",
|
||||
"Core",
|
||||
"ReaderAPI",
|
||||
"NewsBlur",
|
||||
"CloudKitSync",
|
||||
"Feedbin",
|
||||
"LocalAccount",
|
||||
"FeedFinder",
|
||||
"CommonErrors",
|
||||
"Feedly",
|
||||
"FeedDownloader"
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AccountTests",
|
||||
dependencies: ["Account"],
|
||||
resources: [
|
||||
.copy("JSON"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
3
Modules/Account/README.md
Normal file
3
Modules/Account/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Account
|
||||
|
||||
A description of this package.
|
||||
1369
Modules/Account/Sources/Account/Account.swift
Normal file
1369
Modules/Account/Sources/Account/Account.swift
Normal file
File diff suppressed because it is too large
Load Diff
51
Modules/Account/Sources/Account/AccountBehaviors.swift
Normal file
51
Modules/Account/Sources/Account/AccountBehaviors.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// AccountBehaviors.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 9/20/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Account specific behaviors are used to support different sync services. These sync
|
||||
services don't all act the same and we need to reflect their differences in the
|
||||
user interface as much as possible. For example some sync services don't allow
|
||||
feeds to be in the root folder of the account.
|
||||
*/
|
||||
public typealias AccountBehaviors = [AccountBehavior]
|
||||
|
||||
public enum AccountBehavior: Equatable {
|
||||
|
||||
/**
|
||||
Account doesn't support copies of a feed that are in a folder to be made to the root folder.
|
||||
*/
|
||||
case disallowFeedCopyInRootFolder
|
||||
|
||||
/**
|
||||
Account doesn't support feeds in the root folder.
|
||||
*/
|
||||
case disallowFeedInRootFolder
|
||||
|
||||
/**
|
||||
Account doesn't support a feed being in more than one folder.
|
||||
*/
|
||||
case disallowFeedInMultipleFolders
|
||||
|
||||
/**
|
||||
Account doesn't support folders
|
||||
*/
|
||||
case disallowFolderManagement
|
||||
|
||||
/**
|
||||
Account doesn't support OPML imports
|
||||
*/
|
||||
case disallowOPMLImports
|
||||
|
||||
/**
|
||||
Account doesn't allow mark as read after a period of days
|
||||
*/
|
||||
case disallowMarkAsUnreadAfterPeriod(Int)
|
||||
|
||||
}
|
||||
64
Modules/Account/Sources/Account/AccountDelegate.swift
Normal file
64
Modules/Account/Sources/Account/AccountDelegate.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// AccountDelegate.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Web
|
||||
import Secrets
|
||||
|
||||
@MainActor 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]) async
|
||||
|
||||
func refreshAll(for account: Account) async throws
|
||||
func syncArticleStatus(for account: Account) async throws
|
||||
func sendArticleStatus(for account: Account) async throws
|
||||
func refreshArticleStatus(for account: Account) async throws
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL) async throws
|
||||
|
||||
func createFolder(for account: Account, name: String) async throws -> Folder
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws
|
||||
func removeFolder(for account: Account, with folder: Folder) async throws
|
||||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws
|
||||
func addFeed(for account: Account, with: Feed, to container: Container) async throws
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container) async throws
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container) async throws
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws
|
||||
// Called at the end of account’s init method.
|
||||
func accountDidInitialize(_ account: Account)
|
||||
|
||||
func accountWillBeDeleted(_ account: Account)
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials?
|
||||
|
||||
/// 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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// LocalAccountDelegate.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
import ParserObjC
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import Web
|
||||
import Secrets
|
||||
import Core
|
||||
import CommonErrors
|
||||
import FeedFinder
|
||||
import LocalAccount
|
||||
import FeedDownloader
|
||||
|
||||
public enum LocalAccountDelegateError: String, Error {
|
||||
case invalidParameter = "An invalid parameter was used."
|
||||
}
|
||||
|
||||
final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "LocalAccount")
|
||||
|
||||
weak var account: Account?
|
||||
|
||||
let behaviors: AccountBehaviors = []
|
||||
let isOPMLImportInProgress = false
|
||||
|
||||
let server: String? = nil
|
||||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress: DownloadProgress {
|
||||
feedDownloader.downloadProgress
|
||||
}
|
||||
|
||||
let feedDownloader: FeedDownloader
|
||||
|
||||
init() {
|
||||
self.feedDownloader = FeedDownloader()
|
||||
feedDownloader.delegate = self
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account) async throws {
|
||||
|
||||
guard refreshProgress.isComplete else {
|
||||
return
|
||||
}
|
||||
|
||||
let feeds = account.flattenedFeeds()
|
||||
let feedURLStrings = feeds.map { $0.url }
|
||||
let feedURLs = Set(feedURLStrings.compactMap { URL(string: $0) })
|
||||
|
||||
feedDownloader.downloadFeeds(feedURLs)
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account) async throws {
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account) async throws {
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
}
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL) async throws {
|
||||
|
||||
let opmlData = try Data(contentsOf: opmlFile)
|
||||
let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData)
|
||||
|
||||
let opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
|
||||
guard let children = opmlDocument.children else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
account.loadOPMLItems(children)
|
||||
}
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url urlString: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw LocalAccountDelegateError.invalidParameter
|
||||
}
|
||||
|
||||
return try await createRSSFeed(for: account, url: url, editedName: name, container: container)
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
|
||||
|
||||
feed.editedName = name
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
|
||||
|
||||
container.removeFeed(feed)
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
|
||||
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
|
||||
|
||||
container.addFeed(feed)
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
|
||||
|
||||
container.addFeed(feed)
|
||||
}
|
||||
|
||||
func createFolder(for account: Account, name: String) async throws -> Folder {
|
||||
|
||||
guard let folder = account.ensureFolder(with: name) else {
|
||||
throw LocalAccountDelegateError.invalidParameter
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
|
||||
|
||||
folder.name = name
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder) async throws {
|
||||
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws {
|
||||
|
||||
account.addFolder(folder)
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
|
||||
|
||||
try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
|
||||
func suspendNetwork() {
|
||||
Task { @MainActor in
|
||||
await feedDownloader.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
func suspendDatabase() {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
func resume() {
|
||||
feedDownloader.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private extension LocalAccountDelegate {
|
||||
|
||||
func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container) async throws -> Feed {
|
||||
|
||||
// We need to use a batch update here because we need to add the feed to the
|
||||
// container before the name has been downloaded. This will put it in the sidebar
|
||||
// with an Untitled name if we don't delay it being added to the sidebar.
|
||||
BatchUpdate.shared.start()
|
||||
defer {
|
||||
BatchUpdate.shared.end()
|
||||
}
|
||||
|
||||
let feedSpecifiers = try await FeedFinder.find(url: url)
|
||||
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers),
|
||||
let url = URL(string: bestFeedSpecifier.urlString) else {
|
||||
throw AccountError.createErrorNotFound
|
||||
}
|
||||
|
||||
guard !account.hasFeed(withURL: bestFeedSpecifier.urlString) else {
|
||||
throw AccountError.createErrorAlreadySubscribed
|
||||
}
|
||||
|
||||
guard let parsedFeed = await InitialFeedDownloader.download(url) else {
|
||||
throw AccountError.createErrorNotFound
|
||||
}
|
||||
|
||||
let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil)
|
||||
feed.editedName = editedName
|
||||
container.addFeed(feed)
|
||||
|
||||
try await account.update(feed: feed, with: parsedFeed)
|
||||
|
||||
return feed
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalAccountDelegate: FeedDownloaderDelegate {
|
||||
|
||||
func feedDownloader(_: FeedDownloader, requestCompletedForFeedURL feedURL: URL, response: URLResponse?, data: Data?, error: Error?) {
|
||||
|
||||
if let error {
|
||||
logger.debug("Error downloading: \(feedURL) - \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let response, let data, !data.isEmpty else {
|
||||
logger.debug("Missing response and/or data: \(feedURL)")
|
||||
return
|
||||
}
|
||||
guard let account, let feed = account.existingFeed(urlString: feedURL.absoluteString) else {
|
||||
return
|
||||
}
|
||||
|
||||
parseAndUpdateFeed(feed, response: response, data: data)
|
||||
}
|
||||
|
||||
func feedDownloader(_: FeedDownloader, requestCanceledForFeedURL feedURL: URL, response: URLResponse?, data: Data?, error: Error?, reason: FeedDownloader.CancellationReason) {
|
||||
|
||||
logger.debug("Request canceled: \(feedURL) - \(reason)")
|
||||
}
|
||||
|
||||
func feedDownloaderSessionDidComplete(_: FeedDownloader) {
|
||||
|
||||
logger.debug("Feed downloader session did complete")
|
||||
account?.metadata.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
|
||||
func feedDownloader(_: FeedDownloader, conditionalGetInfoFor feedURL: URL) -> HTTPConditionalGetInfo? {
|
||||
|
||||
guard let feed = account?.existingFeed(urlString: feedURL.absoluteString) else {
|
||||
return nil
|
||||
}
|
||||
return feed.conditionalGetInfo
|
||||
}
|
||||
}
|
||||
|
||||
private extension LocalAccountDelegate {
|
||||
|
||||
func parseAndUpdateFeed(_ feed: Feed, response: URLResponse, data: Data) {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
let dataHash = data.md5String
|
||||
if dataHash == feed.contentHash {
|
||||
return
|
||||
}
|
||||
|
||||
let parserData = ParserData(url: feed.url, data: data)
|
||||
|
||||
guard let parsedFeed = try? await FeedParser.parse(parserData) else {
|
||||
return
|
||||
}
|
||||
|
||||
try await self.account?.update(feed: feed, with: parsedFeed)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse)
|
||||
}
|
||||
feed.contentHash = dataHash
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
//
|
||||
// NewsBlurAccountDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Anh-Quang Do on 3/9/20.
|
||||
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Core
|
||||
import Database
|
||||
import Parser
|
||||
import Web
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
import Secrets
|
||||
import NewsBlur
|
||||
import CommonErrors
|
||||
|
||||
final class NewsBlurAccountDelegate: AccountDelegate {
|
||||
|
||||
var behaviors: AccountBehaviors = []
|
||||
|
||||
var isOPMLImportInProgress: Bool = false
|
||||
var server: String? = "newsblur.com"
|
||||
var credentials: Credentials? {
|
||||
didSet {
|
||||
caller.credentials = credentials
|
||||
}
|
||||
}
|
||||
|
||||
var accountMetadata: AccountMetadata? = nil
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
let caller: NewsBlurAPICaller
|
||||
let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur")
|
||||
let syncDatabase: SyncDatabase
|
||||
|
||||
init(dataFolder: String, transport: Transport?) {
|
||||
if let transport = transport {
|
||||
caller = NewsBlurAPICaller(transport: transport)
|
||||
} 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
|
||||
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
|
||||
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
caller = NewsBlurAPICaller(transport: session)
|
||||
}
|
||||
|
||||
syncDatabase = SyncDatabase(databasePath: dataFolder.appending("/DB.sqlite3"))
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account) async throws {
|
||||
|
||||
refreshProgress.addTasks(4)
|
||||
|
||||
try await refreshFeeds(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try await sendArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try await refreshArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try await refreshMissingStories(for: account)
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await sendArticleStatus(for: account)
|
||||
try await refreshArticleStatus(for: account)
|
||||
}
|
||||
|
||||
public func sendArticleStatus(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Sending story statuses…")
|
||||
|
||||
let syncStatuses = (try await self.syncDatabase.selectForProcessing()) ?? Set<SyncStatus>()
|
||||
|
||||
let createUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == false
|
||||
}
|
||||
let deleteUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == true
|
||||
}
|
||||
let createStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == true
|
||||
}
|
||||
let deleteStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == false
|
||||
}
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
do {
|
||||
try await sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: caller.markAsUnread)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
do {
|
||||
try await sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: caller.markAsRead)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
do {
|
||||
try await sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: caller.star)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
do {
|
||||
try await sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: caller.unstar)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing story statuses…")
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
do {
|
||||
let storyHashes = try await caller.retrieveUnreadStoryHashes()
|
||||
await syncStoryReadState(account: account, hashes: storyHashes)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
let storyHashes = try await caller.retrieveStarredStoryHashes()
|
||||
await syncStoryStarredState(account: account, hashes: storyHashes)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func refreshStories(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing stories…")
|
||||
os_log(.debug, log: log, "Refreshing unread stories…")
|
||||
|
||||
let storyHashes = try await caller.retrieveUnreadStoryHashes()
|
||||
if let count = storyHashes?.count, count > 0 {
|
||||
refreshProgress.addTasks((count - 1) / 100 + 1)
|
||||
}
|
||||
|
||||
let storyHashesArray: [NewsBlurStoryHash] = {
|
||||
if let storyHashes {
|
||||
return Array(storyHashes)
|
||||
}
|
||||
return [NewsBlurStoryHash]()
|
||||
}()
|
||||
try await refreshUnreadStories(for: account, hashes: storyHashesArray, updateFetchDate: nil)
|
||||
}
|
||||
|
||||
func refreshMissingStories(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing missing stories…")
|
||||
|
||||
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
let storyHashes = Array(fetchedArticleIDs).map {
|
||||
NewsBlurStoryHash(hash: $0, timestamp: Date())
|
||||
}
|
||||
let chunkedStoryHashes = storyHashes.chunked(into: 100)
|
||||
|
||||
for chunk in chunkedStoryHashes {
|
||||
do {
|
||||
let (stories, _) = try await caller.retrieveStories(hashes: chunk)
|
||||
try await processStories(account: account, stories: stories)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done refreshing missing stories.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil) async throws -> Bool {
|
||||
|
||||
let parsedItems = mapStoriesToParsedItems(stories: stories).filter {
|
||||
guard let datePublished = $0.datePublished, let since = since else {
|
||||
return true
|
||||
}
|
||||
|
||||
return datePublished >= since
|
||||
}
|
||||
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL }).mapValues {
|
||||
Set($0)
|
||||
}
|
||||
|
||||
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
|
||||
return !feedIDsAndItems.isEmpty
|
||||
}
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL) async throws {
|
||||
}
|
||||
|
||||
func createFolder(for account: Account, name: String) async throws -> Folder {
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
try await caller.addFolder(named: name)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
return folder
|
||||
} else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
|
||||
|
||||
guard let folderToRename = folder.name else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
let nameBefore = folder.name
|
||||
|
||||
do {
|
||||
try await caller.renameFolder(with: folderToRename, to: name)
|
||||
folder.name = name
|
||||
} catch {
|
||||
folder.name = nameBefore
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder) async throws {
|
||||
|
||||
guard let folderToRemove = folder.name else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
var feedIDs: [String] = []
|
||||
for feed in folder.topLevelFeeds {
|
||||
if (feed.folderRelationship?.count ?? 0) > 1 {
|
||||
clearFolderRelationship(for: feed, withFolderName: folderToRemove)
|
||||
} else if let feedID = feed.externalID {
|
||||
feedIDs.append(feedID)
|
||||
}
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
try await caller.removeFolder(named: folderToRemove, feedIDs: feedIDs)
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
let folderName = (container as? Folder)?.name
|
||||
|
||||
do {
|
||||
guard let newsBlurFeed = try await caller.addURL(url, folder: folderName) else {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
let feed = try await createFeed(account: account, newsBlurFeed: newsBlurFeed, name: name, container: container)
|
||||
return feed
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
|
||||
|
||||
guard let feedID = feed.externalID else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
do {
|
||||
try await caller.renameFeed(feedID: feedID, newName: name)
|
||||
feed.editedName = name
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
|
||||
|
||||
if let account = container as? Account {
|
||||
account.addFeed(feed)
|
||||
return
|
||||
}
|
||||
|
||||
guard let folder = container as? Folder else {
|
||||
return
|
||||
}
|
||||
let folderName = folder.name ?? ""
|
||||
saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName)
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
|
||||
|
||||
try await deleteFeed(for: account, with: feed, from: container)
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
|
||||
|
||||
guard let feedID = feed.externalID else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
|
||||
try await caller.moveFeed( feedID: feedID, from: (from as? Folder)?.name, to: (to as? Folder)?.name)
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
|
||||
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
return try await account.addFeed(existingFeed, to: container)
|
||||
} else {
|
||||
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws {
|
||||
|
||||
guard let folderName = folder.name else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
var feedsToRestore: [Feed] = []
|
||||
for feed in folder.topLevelFeeds {
|
||||
feedsToRestore.append(feed)
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
}
|
||||
|
||||
do {
|
||||
let folder = try await createFolder(for: account, name: folderName)
|
||||
for feed in feedsToRestore {
|
||||
do {
|
||||
try await restoreFeed(for: account, feed: feed, container: folder)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder error: %@.", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
|
||||
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
try? await syncDatabase.insertStatuses(Set(syncStatuses))
|
||||
|
||||
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
|
||||
try await sendArticleStatus(for: account)
|
||||
}
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
credentials = try? account.retrieveCredentials(type: .newsBlurSessionID)
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
Task { @MainActor in
|
||||
try await caller.logout()
|
||||
}
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
|
||||
|
||||
let caller = NewsBlurAPICaller(transport: transport)
|
||||
caller.credentials = credentials
|
||||
return try await caller.validateCredentials()
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
|
||||
/// Suspend all network activity
|
||||
func suspendNetwork() {
|
||||
caller.suspend()
|
||||
}
|
||||
|
||||
/// Suspend the SQLLite databases
|
||||
func suspendDatabase() {
|
||||
|
||||
Task {
|
||||
await syncDatabase.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||
func resume() {
|
||||
|
||||
Task {
|
||||
caller.resume()
|
||||
await syncDatabase.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NewsBlurAccountDelegate {
|
||||
|
||||
func refreshFeeds(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing feeds…")
|
||||
|
||||
let (feeds, folders) = try await caller.retrieveFeeds()
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
self.syncFolders(account, folders)
|
||||
self.syncFeeds(account, feeds)
|
||||
self.syncFeedFolderRelationship(account, folders)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||
|
||||
guard let folders else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||
|
||||
let folderNames = folders.map { $0.name }
|
||||
|
||||
// Delete any folders not at NewsBlur
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
if !folderNames.contains(folder.name ?? "") {
|
||||
for feed in folder.topLevelFeeds {
|
||||
account.addFeed(feed)
|
||||
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
}
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accountFolderNames: [String] = {
|
||||
if let folders = account.folders {
|
||||
return folders.map { $0.name ?? "" }
|
||||
} else {
|
||||
return [String]()
|
||||
}
|
||||
}()
|
||||
|
||||
// Make any folders NewsBlur has, but we don't
|
||||
// Ignore account-level folder
|
||||
for folderName in folderNames {
|
||||
if !accountFolderNames.contains(folderName) && folderName != " " {
|
||||
_ = account.ensureFolder(with: folderName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) {
|
||||
guard let feeds else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count)
|
||||
|
||||
let newsBlurFeedIDs = feeds.map { String($0.feedID) }
|
||||
|
||||
// Remove any feeds that are no longer in the subscriptions
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
for feed in folder.topLevelFeeds {
|
||||
if !newsBlurFeedIDs.contains(feed.feedID) {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for feed in account.topLevelFeeds {
|
||||
if !newsBlurFeedIDs.contains(feed.feedID) {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Add any feeds we don't have and update any we do
|
||||
var feedsToAdd = Set<NewsBlurFeed>()
|
||||
feeds.forEach { feed in
|
||||
let subFeedID = String(feed.feedID)
|
||||
|
||||
if let feed = account.existingFeed(withFeedID: subFeedID) {
|
||||
feed.name = feed.name
|
||||
// If the name has been changed on the server remove the locally edited name
|
||||
feed.editedName = nil
|
||||
feed.homePageURL = feed.homePageURL
|
||||
feed.externalID = String(feed.feedID)
|
||||
feed.faviconURL = feed.faviconURL
|
||||
}
|
||||
else {
|
||||
feedsToAdd.insert(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does.
|
||||
for feed in feedsToAdd {
|
||||
let feed = account.createFeed(with: feed.name, url: feed.feedURL, feedID: String(feed.feedID), homePageURL: feed.homePageURL)
|
||||
feed.externalID = String(feed.feedID)
|
||||
account.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||
|
||||
guard let folders else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||
|
||||
// Set up some structures to make syncing easier
|
||||
let relationships = folders.map({ $0.asRelationships }).flatMap { $0 }
|
||||
let folderDict = nameToFolderDictionary(with: account.folders)
|
||||
let newsBlurFolderDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in
|
||||
var feedInFolders = dict
|
||||
if var feedInFolder = feedInFolders[relationship.folderName] {
|
||||
feedInFolder.append(relationship)
|
||||
feedInFolders[relationship.folderName] = feedInFolder
|
||||
} else {
|
||||
feedInFolders[relationship.folderName] = [relationship]
|
||||
}
|
||||
return feedInFolders
|
||||
}
|
||||
|
||||
// Sync the folders
|
||||
for (folderName, folderRelationships) in newsBlurFolderDict {
|
||||
guard folderName != " " else {
|
||||
continue
|
||||
}
|
||||
|
||||
let newsBlurFolderFeedIDs = folderRelationships.map { String($0.feedID) }
|
||||
|
||||
guard let folder = folderDict[folderName] else { return }
|
||||
|
||||
// Move any feeds not in the folder to the account
|
||||
for feed in folder.topLevelFeeds {
|
||||
if !newsBlurFolderFeedIDs.contains(feed.feedID) {
|
||||
folder.removeFeed(feed)
|
||||
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||
account.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Add any feeds not in the folder
|
||||
let folderFeedIDs = folder.topLevelFeeds.map { $0.feedID }
|
||||
|
||||
for relationship in folderRelationships {
|
||||
let folderFeedID = String(relationship.feedID)
|
||||
if !folderFeedIDs.contains(folderFeedID) {
|
||||
guard let feed = account.existingFeed(withFeedID: folderFeedID) else {
|
||||
continue
|
||||
}
|
||||
saveFolderRelationship(for: feed, withFolderName: folderName, id: relationship.folderName)
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the account level feeds. If there isn't the special folder, that means all the feeds are
|
||||
// in folders and we need to remove them all from the account level.
|
||||
if let folderRelationships = newsBlurFolderDict[" "] {
|
||||
let newsBlurFolderFeedIDs = folderRelationships.map { String($0.feedID) }
|
||||
for feed in account.topLevelFeeds {
|
||||
if !newsBlurFolderFeedIDs.contains(feed.feedID) {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for feed in account.topLevelFeeds {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) {
|
||||
if var folderRelationship = feed.folderRelationship {
|
||||
folderRelationship[folderName] = nil
|
||||
feed.folderRelationship = folderRelationship
|
||||
}
|
||||
}
|
||||
|
||||
func saveFolderRelationship(for feed: Feed, withFolderName folderName: String, id: String) {
|
||||
if var folderRelationship = feed.folderRelationship {
|
||||
folderRelationship[folderName] = id
|
||||
feed.folderRelationship = folderRelationship
|
||||
} else {
|
||||
feed.folderRelationship = [folderName: id]
|
||||
}
|
||||
}
|
||||
|
||||
func nameToFolderDictionary(with folders: Set<Folder>?) -> [String: Folder] {
|
||||
guard let folders = folders else {
|
||||
return [String: Folder]()
|
||||
}
|
||||
|
||||
var d = [String: Folder]()
|
||||
for folder in folders {
|
||||
let name = folder.name ?? ""
|
||||
if d[name] == nil {
|
||||
d[name] = folder
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?) async throws {
|
||||
|
||||
guard let hashes, !hashes.isEmpty else {
|
||||
if let lastArticleFetch = updateFetchDate {
|
||||
self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch
|
||||
self.accountMetadata?.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let numberOfStories = min(hashes.count, 100) // api limit
|
||||
let hashesToFetch = Array(hashes[..<numberOfStories])
|
||||
|
||||
let (stories, date) = try await caller.retrieveStories(hashes: hashesToFetch)
|
||||
try await processStories(account: account, stories: stories)
|
||||
try await refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date)
|
||||
os_log(.debug, log: self.log, "Done refreshing stories.")
|
||||
}
|
||||
|
||||
func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set<ParsedItem> {
|
||||
guard let stories = stories else { return Set<ParsedItem>() }
|
||||
|
||||
let parsedItems: [ParsedItem] = stories.map { story in
|
||||
let author = Set([ParsedAuthor(name: story.authorName, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, language: nil, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
}
|
||||
|
||||
func sendStoryStatuses(_ statuses: Set<SyncStatus>, throttle: Bool, apiCall: (Set<String>) async throws -> Void) async throws {
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
let storyHashes = statuses.compactMap { $0.articleID }
|
||||
let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit
|
||||
for storyHashGroup in storyHashGroups {
|
||||
|
||||
do {
|
||||
try await apiCall(Set(storyHashGroup))
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription)
|
||||
try? await syncDatabase.resetSelectedForProcessing(Set(storyHashGroup))
|
||||
}
|
||||
}
|
||||
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func syncStoryReadState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
|
||||
|
||||
guard let hashes else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let pendingArticleIDs = (try await syncDatabase.selectPendingReadStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
|
||||
try? await account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsRead(deltaReadArticleIDs)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func syncStoryStarredState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
|
||||
|
||||
guard let hashes else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let pendingArticleIDs = (try await syncDatabase.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
|
||||
try? await account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func createFeed(account: Account, newsBlurFeed: NewsBlurFeed, name: String?, container: Container) async throws -> Feed {
|
||||
|
||||
let feed = account.createFeed(with: newsBlurFeed.name, url: newsBlurFeed.feedURL, feedID: String(newsBlurFeed.feedID), homePageURL: newsBlurFeed.homePageURL)
|
||||
feed.externalID = String(newsBlurFeed.feedID)
|
||||
feed.faviconURL = newsBlurFeed.faviconURL
|
||||
|
||||
try await account.addFeed(feed, to: container)
|
||||
if let name {
|
||||
try await renameFeed(for: account, with: feed, to: name)
|
||||
}
|
||||
try await initialFeedDownload(account: account, feed: feed)
|
||||
return feed
|
||||
}
|
||||
|
||||
func downloadFeed(account: Account, feed: Feed, page: Int) async throws {
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
let (stories, _) = try await caller.retrieveStories(feedID: feed.feedID, page: page)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
guard let stories, stories.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date())
|
||||
|
||||
let hasStories = try await processStories(account: account, stories: stories, since: since)
|
||||
if hasStories {
|
||||
try await downloadFeed(account: account, feed: feed, page: page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func initialFeedDownload(account: Account, feed: Feed) async throws {
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
// Download the initial articles
|
||||
try await downloadFeed(account: account, feed: feed, page: 1)
|
||||
try await refreshArticleStatus(for: account)
|
||||
try await refreshMissingStories(for: account)
|
||||
}
|
||||
|
||||
func deleteFeed(for account: Account, with feed: Feed, from container: Container?) async throws {
|
||||
|
||||
// This error should never happen
|
||||
guard let feedID = feed.externalID else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
let folderName = (container as? Folder)?.name
|
||||
|
||||
do {
|
||||
try await caller.deleteFeed(feedID: feedID, folder: folderName)
|
||||
|
||||
if folderName == nil {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
|
||||
if let folders = account.folders {
|
||||
for folder in folders where folderName != nil && folder.name == folderName {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
if account.existingFeed(withFeedID: feed.feedID) != nil {
|
||||
account.clearFeedMetadata(feed)
|
||||
}
|
||||
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
//
|
||||
// ReaderAPIAccountDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Jeremy Beker on 5/28/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Parser
|
||||
import Web
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
import Secrets
|
||||
import Database
|
||||
import Core
|
||||
import ReaderAPI
|
||||
import CommonErrors
|
||||
import FeedFinder
|
||||
|
||||
final class ReaderAPIAccountDelegate: AccountDelegate {
|
||||
|
||||
private let variant: ReaderAPIVariant
|
||||
|
||||
private let database: SyncDatabase
|
||||
|
||||
private let caller: ReaderAPICaller
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ReaderAPI")
|
||||
|
||||
var behaviors: AccountBehaviors {
|
||||
var behaviors: AccountBehaviors = [.disallowOPMLImports, .disallowFeedInMultipleFolders]
|
||||
if variant == .freshRSS {
|
||||
behaviors.append(.disallowFeedInRootFolder)
|
||||
}
|
||||
return behaviors
|
||||
}
|
||||
|
||||
var server: String? {
|
||||
get {
|
||||
return caller.server
|
||||
}
|
||||
}
|
||||
|
||||
var isOPMLImportInProgress = false
|
||||
|
||||
var credentials: Credentials? {
|
||||
didSet {
|
||||
caller.credentials = credentials
|
||||
}
|
||||
}
|
||||
|
||||
weak var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String, transport: Transport?, variant: ReaderAPIVariant, secretsProvider: SecretsProvider) {
|
||||
|
||||
let databasePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
self.database = SyncDatabase(databasePath: databasePath)
|
||||
|
||||
self.variant = variant
|
||||
|
||||
if transport != nil {
|
||||
self.caller = ReaderAPICaller(transport: transport!, secretsProvider: secretsProvider)
|
||||
} 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
|
||||
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
|
||||
|
||||
self.caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration), secretsProvider: secretsProvider)
|
||||
}
|
||||
|
||||
caller.delegate = self
|
||||
caller.variant = variant
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account) async throws {
|
||||
|
||||
refreshProgress.addTasks(6)
|
||||
|
||||
do {
|
||||
try await refreshAccount(account)
|
||||
|
||||
try await sendArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
let articleIDs = try await caller.retrieveItemIDs(type: .allForAccount)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try? await account.markAsRead(Set(articleIDs))
|
||||
try? await refreshArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
await refreshMissingArticles(account)
|
||||
refreshProgress.clear()
|
||||
|
||||
} catch {
|
||||
refreshProgress.clear()
|
||||
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL {
|
||||
|
||||
self.caller.credentials = basicCredentials
|
||||
|
||||
do {
|
||||
if let apiCredentials = try await caller.validateCredentials(endpoint: endpoint) {
|
||||
try? account.storeCredentials(apiCredentials)
|
||||
caller.credentials = apiCredentials
|
||||
try await refreshAll(for: account)
|
||||
return
|
||||
}
|
||||
throw wrappedError
|
||||
} catch {
|
||||
throw wrappedError
|
||||
}
|
||||
|
||||
} else {
|
||||
throw wrappedError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account) async throws {
|
||||
|
||||
guard variant != .inoreader else {
|
||||
return
|
||||
}
|
||||
|
||||
try await sendArticleStatus(for: account)
|
||||
try await refreshArticleStatus(for: account)
|
||||
}
|
||||
|
||||
public func sendArticleStatus(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Sending article statuses...")
|
||||
|
||||
let syncStatuses = (try await self.database.selectForProcessing()) ?? Set<SyncStatus>()
|
||||
|
||||
let createUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false }
|
||||
let deleteUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true }
|
||||
let createStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true }
|
||||
let deleteStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false }
|
||||
|
||||
await sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries)
|
||||
await sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries)
|
||||
await sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries)
|
||||
await sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries)
|
||||
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
|
||||
os_log(.debug, log: log, "Refreshing article statuses...")
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
let articleIDs = try await caller.retrieveItemIDs(type: .unread)
|
||||
|
||||
do {
|
||||
try await syncArticleReadState(account: account, articleIDs: articleIDs)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
let articleIDs = try await caller.retrieveItemIDs(type: .starred)
|
||||
await syncArticleStarredState(account: account, articleIDs: articleIDs)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
if errorOccurred {
|
||||
throw ReaderAPIError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL) async throws {
|
||||
}
|
||||
|
||||
func createFolder(for account: Account, name: String) async throws -> Folder {
|
||||
|
||||
guard let folder = account.ensureFolder(with: name) else {
|
||||
throw ReaderAPIError.invalidParameter
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.renameTag(oldName: folder.name ?? "", newName: name)
|
||||
folder.externalID = "user/-/label/\(name)"
|
||||
folder.name = name
|
||||
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
refreshProgress.completeTask()
|
||||
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
throw wrappedError
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder) async throws {
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
if feed.folderRelationship?.count ?? 0 > 1 {
|
||||
|
||||
if let subscriptionID = feed.externalID {
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.deleteTagging(subscriptionID: subscriptionID, tagName: folder.nameForDisplay)
|
||||
clearFolderRelationship(for: feed, folderExternalID: folder.externalID)
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
refreshProgress.completeTask()
|
||||
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if let subscriptionID = feed.externalID {
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.deleteSubscription(subscriptionID: subscriptionID)
|
||||
account.clearFeedMetadata(feed)
|
||||
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
|
||||
refreshProgress.completeTask()
|
||||
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.variant == .theOldReader {
|
||||
account.removeFolder(folder: folder)
|
||||
} else {
|
||||
if let folderExternalID = folder.externalID {
|
||||
try await caller.deleteTag(folderExternalID: folderExternalID)
|
||||
}
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
|
||||
|
||||
guard let url = URL(string: url) else {
|
||||
throw ReaderAPIError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTasks(2)
|
||||
|
||||
do {
|
||||
|
||||
let feedSpecifiers = try await FeedFinder.find(url: url)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
let filteredFeedSpecifiers = feedSpecifiers.filter { !$0.urlString.contains("json") }
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: filteredFeedSpecifiers) else {
|
||||
refreshProgress.clear()
|
||||
throw AccountError.createErrorNotFound
|
||||
}
|
||||
|
||||
let subResult = try await caller.createSubscription(url: bestFeedSpecifier.urlString, name: name)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
switch subResult {
|
||||
case .created(let subscription):
|
||||
return try await createFeed(account: account, subscription: subscription, name: name, container: container)
|
||||
case .notFound:
|
||||
throw AccountError.createErrorNotFound
|
||||
}
|
||||
|
||||
} catch {
|
||||
refreshProgress.clear()
|
||||
throw AccountError.createErrorNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
|
||||
|
||||
// This error should never happen
|
||||
guard let subscriptionID = feed.externalID else {
|
||||
assert(feed.externalID != nil)
|
||||
throw ReaderAPIError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.renameSubscription(subscriptionID: subscriptionID, newName: name)
|
||||
feed.editedName = name
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
refreshProgress.completeTask()
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
throw wrappedError
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
|
||||
|
||||
guard let subscriptionID = feed.externalID else {
|
||||
assert(feed.externalID != nil)
|
||||
throw ReaderAPIError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.deleteSubscription(subscriptionID: subscriptionID)
|
||||
|
||||
account.clearFeedMetadata(feed)
|
||||
account.removeFeed(feed)
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
refreshProgress.completeTask()
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
throw wrappedError
|
||||
}
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from sourceContainer: Container, to destinationContainer: Container) async throws {
|
||||
|
||||
if sourceContainer is Account {
|
||||
try await addFeed(for: account, with: feed, to: destinationContainer)
|
||||
} else {
|
||||
|
||||
guard
|
||||
let subscriptionID = feed.externalID,
|
||||
let sourceTag = (sourceContainer as? Folder)?.name,
|
||||
let destinationTag = (destinationContainer as? Folder)?.name
|
||||
else {
|
||||
throw ReaderAPIError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
try await caller.moveSubscription(subscriptionID: subscriptionID, sourceTag: sourceTag, destinationTag: destinationTag)
|
||||
refreshProgress.completeTask()
|
||||
sourceContainer.removeFeed(feed)
|
||||
destinationContainer.addFeed(feed)
|
||||
refreshProgress.completeTask()
|
||||
} catch {
|
||||
refreshProgress.completeTask()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
|
||||
|
||||
if let folder = container as? Folder, let feedExternalID = feed.externalID {
|
||||
|
||||
refreshProgress.addTask()
|
||||
|
||||
do {
|
||||
|
||||
try await caller.createTagging(subscriptionID: feedExternalID, tagName: folder.name ?? "")
|
||||
|
||||
self.saveFolderRelationship(for: feed, folderExternalID: folder.externalID, feedExternalID: feedExternalID)
|
||||
account.removeFeed(feed)
|
||||
folder.addFeed(feed)
|
||||
|
||||
refreshProgress.completeTask()
|
||||
|
||||
} catch {
|
||||
|
||||
refreshProgress.completeTask()
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
throw wrappedError
|
||||
}
|
||||
} else {
|
||||
|
||||
if let account = container as? Account {
|
||||
account.addFeedIfNotInAnyFolder(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
|
||||
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
try await account.addFeed(existingFeed, to: container)
|
||||
}
|
||||
else {
|
||||
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws {
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
|
||||
do {
|
||||
try await restoreFeed(for: account, feed: feed, container: folder)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
account.addFolder(folder)
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
|
||||
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try await self.database.insertStatuses(Set(syncStatuses))
|
||||
|
||||
if let count = try await self.database.selectPendingCount(), count > 100 {
|
||||
try await sendArticleStatus(for: account)
|
||||
}
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
credentials = try? account.retrieveCredentials(type: .readerAPIKey)
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
|
||||
|
||||
guard let endpoint else {
|
||||
throw TransportError.noURL
|
||||
}
|
||||
|
||||
let caller = ReaderAPICaller(transport: transport, secretsProvider: secretsProvider)
|
||||
caller.credentials = credentials
|
||||
|
||||
return try await caller.validateCredentials(endpoint: endpoint)
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
|
||||
/// Suspend all network activity
|
||||
func suspendNetwork() {
|
||||
caller.cancelAll()
|
||||
}
|
||||
|
||||
/// Suspend the SQLite databases
|
||||
func suspendDatabase() {
|
||||
|
||||
Task {
|
||||
await database.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||
func resume() {
|
||||
|
||||
Task {
|
||||
await database.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension ReaderAPIAccountDelegate {
|
||||
|
||||
func refreshAccount(_ account: Account) async throws {
|
||||
|
||||
let tags = try await caller.retrieveTags()
|
||||
refreshProgress.completeTask()
|
||||
|
||||
let subscriptions = try await caller.retrieveSubscriptions()
|
||||
refreshProgress.completeTask()
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
self.syncFolders(account, tags)
|
||||
self.syncFeeds(account, subscriptions)
|
||||
self.syncFeedFolderRelationship(account, subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFolders(_ account: Account, _ tags: [ReaderAPITag]?) {
|
||||
guard let tags = tags else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
let folderTags: [ReaderAPITag]
|
||||
if variant == .inoreader {
|
||||
folderTags = tags.filter{ $0.type == "folder" }
|
||||
} else {
|
||||
folderTags = tags.filter{ $0.tagID.contains("/label/") }
|
||||
}
|
||||
|
||||
guard !folderTags.isEmpty else { return }
|
||||
|
||||
os_log(.debug, log: log, "Syncing folders with %ld tags.", folderTags.count)
|
||||
|
||||
let readerFolderExternalIDs = folderTags.compactMap { $0.tagID }
|
||||
|
||||
// Delete any folders not at Reader
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
if !readerFolderExternalIDs.contains(folder.externalID ?? "") {
|
||||
for feed in folder.topLevelFeeds {
|
||||
account.addFeed(feed)
|
||||
clearFolderRelationship(for: feed, folderExternalID: folder.externalID)
|
||||
}
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let folderExternalIDs: [String] = {
|
||||
if let folders = account.folders {
|
||||
return folders.compactMap { $0.externalID }
|
||||
} else {
|
||||
return [String]()
|
||||
}
|
||||
}()
|
||||
|
||||
// Make any folders Reader has, but we don't
|
||||
for tag in folderTags {
|
||||
if !folderExternalIDs.contains(tag.tagID) {
|
||||
let folder = account.ensureFolder(with: tag.folderName ?? "None")
|
||||
folder?.externalID = tag.tagID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncFeeds(_ account: Account, _ subscriptions: [ReaderAPISubscription]?) {
|
||||
|
||||
guard let subscriptions = subscriptions else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing feeds with %ld subscriptions.", subscriptions.count)
|
||||
|
||||
let subFeedIDs = subscriptions.map { $0.feedID }
|
||||
|
||||
// Remove any feeds that are no longer in the subscriptions
|
||||
if let folders = account.folders {
|
||||
for folder in folders {
|
||||
for feed in folder.topLevelFeeds {
|
||||
if !subFeedIDs.contains(feed.feedID) {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for feed in account.topLevelFeeds {
|
||||
if !subFeedIDs.contains(feed.feedID) {
|
||||
account.clearFeedMetadata(feed)
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Add any feeds we don't have and update any we do
|
||||
for subscription in subscriptions {
|
||||
|
||||
if let feed = account.existingFeed(withFeedID: subscription.feedID) {
|
||||
feed.name = subscription.name
|
||||
feed.editedName = nil
|
||||
feed.homePageURL = subscription.homePageURL
|
||||
} else {
|
||||
let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subscription.feedID, homePageURL: subscription.homePageURL)
|
||||
feed.externalID = subscription.feedID
|
||||
account.addFeed(feed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func syncFeedFolderRelationship(_ account: Account, _ subscriptions: [ReaderAPISubscription]?) {
|
||||
guard let subscriptions = subscriptions else { return }
|
||||
assert(Thread.isMainThread)
|
||||
os_log(.debug, log: log, "Syncing taggings with %ld subscriptions.", subscriptions.count)
|
||||
|
||||
// Set up some structures to make syncing easier
|
||||
let folderDict = externalIDToFolderDictionary(with: account.folders)
|
||||
let taggingsDict = subscriptions.reduce([String: [ReaderAPISubscription]]()) { (dict, subscription) in
|
||||
var taggedFeeds = dict
|
||||
|
||||
for category in subscription.categories {
|
||||
if var taggedFeed = taggedFeeds[category.categoryID] {
|
||||
taggedFeed.append(subscription)
|
||||
taggedFeeds[category.categoryID] = taggedFeed
|
||||
} else {
|
||||
taggedFeeds[category.categoryID] = [subscription]
|
||||
}
|
||||
}
|
||||
|
||||
return taggedFeeds
|
||||
}
|
||||
|
||||
// Sync the folders
|
||||
for (folderExternalID, groupedTaggings) in taggingsDict {
|
||||
guard let folder = folderDict[folderExternalID] else { return }
|
||||
let taggingFeedIDs = groupedTaggings.map { $0.feedID }
|
||||
|
||||
// Move any feeds not in the folder to the account
|
||||
for feed in folder.topLevelFeeds {
|
||||
if !taggingFeedIDs.contains(feed.feedID) {
|
||||
folder.removeFeed(feed)
|
||||
clearFolderRelationship(for: feed, folderExternalID: folder.externalID)
|
||||
account.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// Add any feeds not in the folder
|
||||
let folderFeedIDs = folder.topLevelFeeds.map { $0.feedID }
|
||||
|
||||
for subscription in groupedTaggings {
|
||||
let taggingFeedID = subscription.feedID
|
||||
if !folderFeedIDs.contains(taggingFeedID) {
|
||||
guard let feed = account.existingFeed(withFeedID: taggingFeedID) else {
|
||||
continue
|
||||
}
|
||||
saveFolderRelationship(for: feed, folderExternalID: folderExternalID, feedExternalID: subscription.feedID)
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let taggedFeedIDs = Set(subscriptions.filter({ !$0.categories.isEmpty }).map { String($0.feedID) })
|
||||
|
||||
// Remove all feeds from the account container that have a tag
|
||||
for feed in account.topLevelFeeds {
|
||||
if taggedFeedIDs.contains(feed.feedID) {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func externalIDToFolderDictionary(with folders: Set<Folder>?) -> [String: Folder] {
|
||||
guard let folders = folders else {
|
||||
return [String: Folder]()
|
||||
}
|
||||
|
||||
var d = [String: Folder]()
|
||||
|
||||
for folder in folders {
|
||||
if let externalID = folder.externalID, d[externalID] == nil {
|
||||
d[externalID] = folder
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func sendArticleStatuses(_ statuses: Set<SyncStatus>, apiCall: ([String]) async throws -> Void) async {
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let articleIDs = statuses.compactMap { $0.articleID }
|
||||
let articleIDGroups = articleIDs.chunked(into: 1000)
|
||||
for articleIDGroup in articleIDGroups {
|
||||
|
||||
do {
|
||||
let _ = try await apiCall(articleIDGroup)
|
||||
try? await database.deleteSelectedForProcessing(Set(articleIDGroup))
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
|
||||
try? await database.resetSelectedForProcessing(Set(articleIDGroup))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearFolderRelationship(for feed: Feed, folderExternalID: String?) {
|
||||
guard var folderRelationship = feed.folderRelationship, let folderExternalID = folderExternalID else { return }
|
||||
folderRelationship[folderExternalID] = nil
|
||||
feed.folderRelationship = folderRelationship
|
||||
}
|
||||
|
||||
func saveFolderRelationship(for feed: Feed, folderExternalID: String?, feedExternalID: String) {
|
||||
guard let folderExternalID = folderExternalID else { return }
|
||||
if var folderRelationship = feed.folderRelationship {
|
||||
folderRelationship[folderExternalID] = feedExternalID
|
||||
feed.folderRelationship = folderRelationship
|
||||
} else {
|
||||
feed.folderRelationship = [folderExternalID: feedExternalID]
|
||||
}
|
||||
}
|
||||
|
||||
func createFeed( account: Account, subscription sub: ReaderAPISubscription, name: String?, container: Container) async throws -> Feed {
|
||||
|
||||
let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL)
|
||||
feed.externalID = String(sub.feedID)
|
||||
|
||||
try await account.addFeed(feed, to: container)
|
||||
if let name {
|
||||
try await renameFeed(for: account, with: feed, to: name)
|
||||
}
|
||||
try await initialFeedDownload(account: account, feed: feed)
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
|
||||
@discardableResult
|
||||
func initialFeedDownload( account: Account, feed: Feed) async throws -> Feed {
|
||||
|
||||
refreshProgress.addTasks(5)
|
||||
|
||||
// Download the initial articles
|
||||
let articleIDs = try await caller.retrieveItemIDs(type: .allForFeed, feedID: feed.feedID)
|
||||
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try? await account.markAsRead(Set(articleIDs))
|
||||
refreshProgress.completeTask()
|
||||
|
||||
try? await refreshArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
await refreshMissingArticles(account)
|
||||
refreshProgress.clear()
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
func refreshMissingArticles(_ account: Account) async {
|
||||
|
||||
do {
|
||||
let fetchedArticleIDs = (try? await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate()) ?? Set<String>()
|
||||
|
||||
if fetchedArticleIDs.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Refreshing missing articles...")
|
||||
|
||||
let articleIDs = Array(fetchedArticleIDs)
|
||||
let chunkedArticleIDs = articleIDs.chunked(into: 150)
|
||||
|
||||
refreshProgress.addTasks(chunkedArticleIDs.count - 1)
|
||||
|
||||
for chunk in chunkedArticleIDs {
|
||||
|
||||
do {
|
||||
let entries = try await caller.retrieveEntries(articleIDs: chunk)
|
||||
refreshProgress.completeTask()
|
||||
await processEntries(account: account, entries: entries)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
refreshProgress.completeTask()
|
||||
os_log(.debug, log: self.log, "Done refreshing missing articles.")
|
||||
}
|
||||
}
|
||||
|
||||
func processEntries(account: Account, entries: [ReaderAPIEntry]?) async {
|
||||
|
||||
let parsedItems = mapEntriesToParsedItems(account: account, entries: entries)
|
||||
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
|
||||
|
||||
try? await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
|
||||
}
|
||||
|
||||
func mapEntriesToParsedItems(account: Account, entries: [ReaderAPIEntry]?) -> Set<ParsedItem> {
|
||||
|
||||
guard let entries else {
|
||||
return Set<ParsedItem>()
|
||||
}
|
||||
|
||||
let parsedItems: [ParsedItem] = entries.compactMap { entry in
|
||||
|
||||
guard let streamID = entry.origin.streamID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let authors: Set<ParsedAuthor>? = {
|
||||
guard let name = entry.author else {
|
||||
return nil
|
||||
}
|
||||
return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
}()
|
||||
|
||||
return ParsedItem(syncServiceID: entry.uniqueID(variant: variant),
|
||||
uniqueID: entry.uniqueID(variant: variant),
|
||||
feedURL: streamID,
|
||||
url: nil,
|
||||
externalURL: entry.alternates?.first?.url,
|
||||
title: entry.title,
|
||||
language: nil,
|
||||
contentHTML: entry.summary.content,
|
||||
contentText: nil,
|
||||
summary: entry.summary.content,
|
||||
imageURL: nil,
|
||||
bannerImageURL: nil,
|
||||
datePublished: entry.parseDatePublished(),
|
||||
dateModified: nil,
|
||||
authors: authors,
|
||||
tags: nil,
|
||||
attachments: nil)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
}
|
||||
|
||||
func syncArticleReadState(account: Account, articleIDs: [String]?) async throws {
|
||||
|
||||
guard let articleIDs else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
|
||||
let pendingArticleIDs = (try await self.database.selectPendingReadStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
let updatableReaderUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = updatableReaderUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
|
||||
try? await account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableReaderUnreadArticleIDs)
|
||||
try? await account.markAsRead(deltaReadArticleIDs)
|
||||
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Article Read Status failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncArticleStarredState(account: Account, articleIDs: [String]?) async {
|
||||
|
||||
guard let articleIDs else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
let updatableReaderUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = updatableReaderUnreadArticleIDs.subtracting(currentStarredArticleIDs)
|
||||
try? await account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableReaderUnreadArticleIDs)
|
||||
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReaderAPIAccountDelegate: ReaderAPICallerDelegate {
|
||||
|
||||
var endpointURL: URL? {
|
||||
accountMetadata?.endpointURL
|
||||
}
|
||||
|
||||
var lastArticleFetchStartTime: Date? {
|
||||
get {
|
||||
accountMetadata?.lastArticleFetchStartTime
|
||||
}
|
||||
set {
|
||||
accountMetadata?.lastArticleFetchStartTime = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var lastArticleFetchEndTime: Date? {
|
||||
get {
|
||||
accountMetadata?.lastArticleFetchEndTime
|
||||
}
|
||||
set {
|
||||
accountMetadata?.lastArticleFetchEndTime = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Modules/Account/Sources/Account/AccountError.swift
Normal file
26
Modules/Account/Sources/Account/AccountError.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// AccountError.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 5/26/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import CommonErrors
|
||||
|
||||
public extension AccountError {
|
||||
|
||||
@MainActor var account: Account? {
|
||||
if case .wrappedError(_, let accountID, _) = self {
|
||||
return AccountManager.shared.existingAccount(with: accountID)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor static func wrappedError(error: Error, account: Account) -> AccountError {
|
||||
wrappedError(error: error, accountID: account.accountID, accountName: account.nameForDisplay)
|
||||
}
|
||||
}
|
||||
473
Modules/Account/Sources/Account/AccountManager.swift
Normal file
473
Modules/Account/Sources/Account/AccountManager.swift
Normal file
@@ -0,0 +1,473 @@
|
||||
//
|
||||
// AccountManager.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/18/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
import Database
|
||||
import Secrets
|
||||
|
||||
@MainActor public final class AccountManager: UnreadCountProvider {
|
||||
|
||||
@MainActor 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"
|
||||
|
||||
private let secretsProvider: SecretsProvider
|
||||
|
||||
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 var combinedRefreshProgress: CombinedRefreshProgress {
|
||||
let downloadProgressArray = activeAccounts.map { $0.refreshProgress }
|
||||
return CombinedRefreshProgress(downloadProgressArray: downloadProgressArray)
|
||||
}
|
||||
|
||||
public init(accountsFolder: String, secretsProvider: SecretsProvider) {
|
||||
|
||||
self.accountsFolder = accountsFolder
|
||||
self.secretsProvider = secretsProvider
|
||||
|
||||
// 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, secretsProvider: secretsProvider)
|
||||
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, secretsProvider: secretsProvider)
|
||||
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.accountType == 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 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]) async {
|
||||
|
||||
for account in activeAccounts {
|
||||
await account.receiveRemoteNotification(userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshAll(errorHandler: ((Error) -> Void)? = nil) async {
|
||||
|
||||
guard Reachability.internetIsReachable else {
|
||||
return
|
||||
}
|
||||
|
||||
var syncErrors = [AccountSyncError]()
|
||||
|
||||
await withTaskGroup(of: Void.self) { taskGroup in
|
||||
|
||||
for account in self.activeAccounts {
|
||||
|
||||
taskGroup.addTask { @MainActor in
|
||||
do {
|
||||
try await account.refreshAll()
|
||||
} catch {
|
||||
|
||||
if let errorHandler {
|
||||
errorHandler(error)
|
||||
}
|
||||
|
||||
let syncError = AccountSyncError(account: account, error: error)
|
||||
syncErrors.append(syncError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !syncErrors.isEmpty {
|
||||
NotificationCenter.default.post(Notification(name: .AccountsDidFailToSyncWithErrors, object: self, userInfo: [Account.UserInfoKey.syncErrors: syncErrors]))
|
||||
}
|
||||
}
|
||||
|
||||
public func sendArticleStatusAll() async {
|
||||
|
||||
await withTaskGroup(of: Void.self) { taskGroup in
|
||||
|
||||
for account in activeAccounts {
|
||||
taskGroup.addTask {
|
||||
try? await account.sendArticleStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func syncArticleStatusAll() async {
|
||||
|
||||
await withTaskGroup(of: Void.self) { taskGroup in
|
||||
|
||||
for account in activeAccounts {
|
||||
taskGroup.addTask {
|
||||
try? await account.syncArticleStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) async throws -> Set<Article> {
|
||||
|
||||
guard activeAccounts.count > 0 else {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
var allFetchedArticles = Set<Article>()
|
||||
for account in activeAccounts {
|
||||
let articles = try await account.articles(for: fetchType)
|
||||
allFetchedArticles.formUnion(articles)
|
||||
}
|
||||
|
||||
return allFetchedArticles
|
||||
}
|
||||
|
||||
// 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, secretsProvider: secretsProvider)
|
||||
}
|
||||
|
||||
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.accountType, 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)
|
||||
}
|
||||
}
|
||||
98
Modules/Account/Sources/Account/AccountMetadata.swift
Normal file
98
Modules/Account/Sources/Account/AccountMetadata.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// AccountMetadata.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Brent Simmons on 3/3/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 externalID: String? {
|
||||
didSet {
|
||||
if externalID != oldValue {
|
||||
valueDidChange(.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: AccountMetadataDelegate?
|
||||
|
||||
func valueDidChange(_ key: CodingKeys) {
|
||||
delegate?.valueDidChange(self, key: key)
|
||||
}
|
||||
}
|
||||
67
Modules/Account/Sources/Account/AccountMetadataFile.swift
Normal file
67
Modules/Account/Sources/Account/AccountMetadataFile.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 Core
|
||||
|
||||
@MainActor final class AccountMetadataFile {
|
||||
|
||||
private let fileURL: URL
|
||||
private let account: Account
|
||||
private let dataFile: DataFile
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AccountMetadataFile")
|
||||
|
||||
init(filename: String, account: Account) {
|
||||
|
||||
self.fileURL = URL(fileURLWithPath: filename)
|
||||
self.account = account
|
||||
self.dataFile = DataFile(fileURL: self.fileURL)
|
||||
|
||||
self.dataFile.delegate = self
|
||||
}
|
||||
|
||||
func markAsDirty() {
|
||||
|
||||
dataFile.markAsDirty()
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
dataFile.save()
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountMetadataFile: DataFileDelegate {
|
||||
|
||||
func data(for dataFile: DataFile) -> Data? {
|
||||
|
||||
guard !account.isDeleted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
|
||||
return try? encoder.encode(account.metadata)
|
||||
}
|
||||
|
||||
func dataFileWriteToDiskDidFail(for dataFile: DataFile, error: Error) {
|
||||
|
||||
logger.error("AccountMetadataFile save to disk failed for \(self.fileURL): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
31
Modules/Account/Sources/Account/AccountSyncError.swift
Normal file
31
Modules/Account/Sources/Account/AccountSyncError.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// 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 {
|
||||
|
||||
// OSLog is supposedly Sendable and will be annotated that way in the future:
|
||||
// https://forums.developer.apple.com/forums/thread/747816
|
||||
nonisolated(unsafe) 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: Self.log, "%@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
66
Modules/Account/Sources/Account/ArticleFetcher.swift
Normal file
66
Modules/Account/Sources/Account/ArticleFetcher.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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 {
|
||||
|
||||
@MainActor func fetchArticles() async throws -> Set<Article>
|
||||
@MainActor func fetchUnreadArticles() async throws -> Set<Article>
|
||||
}
|
||||
|
||||
extension Feed: ArticleFetcher {
|
||||
|
||||
public func fetchArticles() async throws -> Set<Article> {
|
||||
|
||||
guard let account else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
return try await account.articles(feed: self)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticles() async throws -> Set<Article> {
|
||||
|
||||
guard let account else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
return try await account.unreadArticles(feed: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Folder: ArticleFetcher {
|
||||
|
||||
public func fetchArticles() async throws -> Set<Articles.Article> {
|
||||
|
||||
try await articles(unreadOnly: false)
|
||||
}
|
||||
|
||||
public func fetchUnreadArticles() async throws -> Set<Article> {
|
||||
|
||||
try await articles(unreadOnly: true)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Folder {
|
||||
|
||||
func articles(unreadOnly: Bool = false) async throws -> Set<Article> {
|
||||
|
||||
guard let account else {
|
||||
assertionFailure("Expected folder.account, but got nil.")
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
return try await account.articles(for: .folder(self, unreadOnly))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// CombinedRefreshProgress.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/7/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
|
||||
// Combines the refresh progress of multiple accounts into one struct,
|
||||
// for use by refresh status view and so on.
|
||||
|
||||
public struct CombinedRefreshProgress: Sendable {
|
||||
|
||||
public let numberOfTasks: Int
|
||||
public let numberRemaining: Int
|
||||
public let numberCompleted: Int
|
||||
public let isComplete: Bool
|
||||
|
||||
init(numberOfTasks: Int, numberRemaining: Int, numberCompleted: Int) {
|
||||
self.numberOfTasks = max(numberOfTasks, 0)
|
||||
self.numberRemaining = max(numberRemaining, 0)
|
||||
self.numberCompleted = max(numberCompleted, 0)
|
||||
self.isComplete = numberRemaining < 1
|
||||
}
|
||||
|
||||
public init(downloadProgressArray: [DownloadProgress]) {
|
||||
var numberOfTasks = 0
|
||||
var numberRemaining = 0
|
||||
var numberCompleted = 0
|
||||
|
||||
for downloadProgress in downloadProgressArray {
|
||||
let taskCounts = downloadProgress.taskCounts
|
||||
numberOfTasks += taskCounts.numberOfTasks
|
||||
numberRemaining += taskCounts.numberRemaining
|
||||
numberCompleted += taskCounts.numberCompleted
|
||||
}
|
||||
|
||||
self.init(numberOfTasks: numberOfTasks, numberRemaining: numberRemaining, numberCompleted: numberCompleted)
|
||||
}
|
||||
}
|
||||
166
Modules/Account/Sources/Account/Container.swift
Normal file
166
Modules/Account/Sources/Account/Container.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
//
|
||||
// Container.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 4/17/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
public static let ChildrenDidChange = Notification.Name("ChildrenDidChange")
|
||||
}
|
||||
|
||||
@MainActor 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)
|
||||
}
|
||||
}
|
||||
|
||||
117
Modules/Account/Sources/Account/ContainerIdentifier.swift
Normal file
117
Modules/Account/Sources/Account/ContainerIdentifier.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// ContainerIdentifier.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 11/24/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ContainerIdentifiable {
|
||||
|
||||
@MainActor var containerID: ContainerIdentifier? { get }
|
||||
}
|
||||
|
||||
public enum ContainerIdentifier: Hashable, Equatable, Sendable {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
|
||||
switch self {
|
||||
case .smartFeedController:
|
||||
hasher.combine(0)
|
||||
case .account(let accountID):
|
||||
hasher.combine(accountID)
|
||||
case .folder(let accountID, let folderName):
|
||||
hasher.combine(accountID)
|
||||
hasher.combine(folderName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContainerIdentifier: Encodable {
|
||||
enum CodingKeys: CodingKey {
|
||||
case type
|
||||
case accountID
|
||||
case folderName
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .smartFeedController:
|
||||
try container.encode("smartFeedController", forKey: .type)
|
||||
case .account(let accountID):
|
||||
try container.encode("account", forKey: .type)
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
case .folder(let accountID, let folderName):
|
||||
try container.encode("folder", forKey: .type)
|
||||
try container.encode(accountID, forKey: .accountID)
|
||||
try container.encode(folderName, forKey: .folderName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContainerIdentifier: Decodable {
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case "smartFeedController":
|
||||
self = .smartFeedController
|
||||
case "account":
|
||||
let accountID = try container.decode(String.self, forKey: .accountID)
|
||||
self = .account(accountID)
|
||||
default:
|
||||
let accountID = try container.decode(String.self, forKey: .accountID)
|
||||
let folderName = try container.decode(String.self, forKey: .folderName)
|
||||
self = .folder(accountID, folderName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
49
Modules/Account/Sources/Account/ContainerPath.swift
Normal file
49
Modules/Account/Sources/Account/ContainerPath.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// ContainerPath.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 11/4/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Used to identify the parent of an object.
|
||||
// Mainly used with deleting objects and undo/redo.
|
||||
// Especially redo. The idea is to put something back in the right place.
|
||||
|
||||
@MainActor 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)
|
||||
}
|
||||
}
|
||||
63
Modules/Account/Sources/Account/DataExtensions.swift
Normal file
63
Modules/Account/Sources/Account/DataExtensions.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@MainActor 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)
|
||||
}
|
||||
|
||||
@MainActor var feed: Feed? {
|
||||
return account?.existingFeed(withFeedID: feedID)
|
||||
}
|
||||
}
|
||||
|
||||
307
Modules/Account/Sources/Account/Feed.swift
Normal file
307
Modules/Account/Sources/Account/Feed.swift
Normal file
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// Feed.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import Articles
|
||||
import Core
|
||||
|
||||
@MainActor public final class Feed: Renamable, DisplayNameProvider, UnreadCountProvider, Hashable {
|
||||
|
||||
public weak var account: Account?
|
||||
public let url: String
|
||||
public let feedID: String
|
||||
|
||||
public var homePageURL: String? {
|
||||
get {
|
||||
return metadata.homePageURL
|
||||
}
|
||||
set {
|
||||
if let url = newValue, !url.isEmpty {
|
||||
metadata.homePageURL = url.normalizedURL
|
||||
}
|
||||
else {
|
||||
metadata.homePageURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this is available only if the icon URL was available in the feed.
|
||||
// The icon URL is a JSON-Feed-only feature.
|
||||
// Otherwise we find an icon URL via other means, but we don’t store it
|
||||
// as part of feed metadata.
|
||||
public var iconURL: String? {
|
||||
get {
|
||||
return metadata.iconURL
|
||||
}
|
||||
set {
|
||||
metadata.iconURL = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this is available only if the favicon URL was available in the feed.
|
||||
// The favicon URL is a JSON-Feed-only feature.
|
||||
// Otherwise we find a favicon URL via other means, but we don’t store it
|
||||
// as part of feed metadata.
|
||||
public var faviconURL: String? {
|
||||
get {
|
||||
return metadata.faviconURL
|
||||
}
|
||||
set {
|
||||
metadata.faviconURL = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String? {
|
||||
didSet {
|
||||
if name != oldValue {
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var authors: Set<Author>? {
|
||||
get {
|
||||
if let authorsArray = metadata.authors {
|
||||
return Set(authorsArray)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
set {
|
||||
if let authorsSet = newValue {
|
||||
metadata.authors = Array(authorsSet)
|
||||
}
|
||||
else {
|
||||
metadata.authors = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var editedName: String? {
|
||||
// Don’t let editedName == ""
|
||||
get {
|
||||
guard let s = metadata.editedName, !s.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
set {
|
||||
if newValue != editedName {
|
||||
if let valueToSet = newValue, !valueToSet.isEmpty {
|
||||
metadata.editedName = valueToSet
|
||||
}
|
||||
else {
|
||||
metadata.editedName = nil
|
||||
}
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var conditionalGetInfo: HTTPConditionalGetInfo? {
|
||||
get {
|
||||
return metadata.conditionalGetInfo
|
||||
}
|
||||
set {
|
||||
metadata.conditionalGetInfo = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var contentHash: String? {
|
||||
get {
|
||||
return metadata.contentHash
|
||||
}
|
||||
set {
|
||||
metadata.contentHash = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var shouldSendUserNotificationForNewArticles: Bool? {
|
||||
get {
|
||||
return metadata.shouldSendUserNotificationForNewArticles
|
||||
}
|
||||
set {
|
||||
metadata.shouldSendUserNotificationForNewArticles = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var isArticleExtractorAlwaysOn: Bool? {
|
||||
get {
|
||||
metadata.isArticleExtractorAlwaysOn
|
||||
}
|
||||
set {
|
||||
metadata.isArticleExtractorAlwaysOn = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var sinceToken: String? {
|
||||
get {
|
||||
return metadata.sinceToken
|
||||
}
|
||||
set {
|
||||
metadata.sinceToken = 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) async throws {
|
||||
|
||||
guard let account else {
|
||||
return
|
||||
}
|
||||
|
||||
try await account.renameFeed(self, to: newName)
|
||||
}
|
||||
|
||||
// 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.feedID = metadata.feedID
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
public func dropConditionalGetInfo() {
|
||||
conditionalGetInfo = nil
|
||||
contentHash = nil
|
||||
sinceToken = nil
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
nonisolated public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(feedID)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
nonisolated public class func ==(lhs: Feed, rhs: Feed) -> Bool {
|
||||
return lhs.feedID == rhs.feedID && lhs.accountID == rhs.accountID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OPMLRepresentable
|
||||
|
||||
extension Feed: OPMLRepresentable {
|
||||
|
||||
public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String {
|
||||
// https://github.com/brentsimmons/NetNewsWire/issues/527
|
||||
// Don’t use nameForDisplay because that can result in a feed name "Untitled" written to disk,
|
||||
// which NetNewsWire may take later to be the actual name.
|
||||
var nameToUse = editedName
|
||||
if nameToUse == nil {
|
||||
nameToUse = name
|
||||
}
|
||||
if nameToUse == nil {
|
||||
nameToUse = ""
|
||||
}
|
||||
let escapedName = nameToUse!.escapingSpecialXMLCharacters
|
||||
|
||||
var escapedHomePageURL = ""
|
||||
if let homePageURL = homePageURL {
|
||||
escapedHomePageURL = homePageURL.escapingSpecialXMLCharacters
|
||||
}
|
||||
let escapedFeedURL = url.escapingSpecialXMLCharacters
|
||||
|
||||
var s = "<outline text=\"\(escapedName)\" title=\"\(escapedName)\" description=\"\" type=\"rss\" version=\"RSS\" htmlUrl=\"\(escapedHomePageURL)\" xmlUrl=\"\(escapedFeedURL)\"/>\n"
|
||||
s = s.prepending(tabCount: indentLevel)
|
||||
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
extension Set where Element == Feed {
|
||||
|
||||
@MainActor func feedIDs() -> Set<String> {
|
||||
return Set<String>(map { $0.feedID })
|
||||
}
|
||||
|
||||
@MainActor 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
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
143
Modules/Account/Sources/Account/FeedMetadata.swift
Normal file
143
Modules/Account/Sources/Account/FeedMetadata.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// FeedMetadata.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 3/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import Articles
|
||||
|
||||
protocol FeedMetadataDelegate: AnyObject {
|
||||
func valueDidChange(_ feedMetadata: FeedMetadata, key: FeedMetadata.CodingKeys)
|
||||
}
|
||||
|
||||
final class FeedMetadata: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case feedID = "feedID"
|
||||
case homePageURL
|
||||
case iconURL
|
||||
case faviconURL
|
||||
case editedName
|
||||
case authors
|
||||
case contentHash
|
||||
case shouldSendUserNotificationForNewArticles = "isNotifyAboutNewArticles"
|
||||
case isArticleExtractorAlwaysOn
|
||||
case conditionalGetInfo
|
||||
case sinceToken
|
||||
case externalID = "subscriptionID"
|
||||
case folderRelationship
|
||||
}
|
||||
|
||||
let feedID: String
|
||||
|
||||
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 shouldSendUserNotificationForNewArticles: Bool? {
|
||||
didSet {
|
||||
if shouldSendUserNotificationForNewArticles != oldValue {
|
||||
valueDidChange(.shouldSendUserNotificationForNewArticles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 sinceToken: String? {
|
||||
didSet {
|
||||
if externalID != oldValue {
|
||||
valueDidChange(.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
80
Modules/Account/Sources/Account/FeedMetadataFile.swift
Normal file
80
Modules/Account/Sources/Account/FeedMetadataFile.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// 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 Core
|
||||
|
||||
@MainActor final class FeedMetadataFile {
|
||||
|
||||
private let fileURL: URL
|
||||
private let account: Account
|
||||
private let dataFile: DataFile
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FeedMetadataFile")
|
||||
|
||||
init(filename: String, account: Account) {
|
||||
|
||||
self.fileURL = URL(fileURLWithPath: filename)
|
||||
self.account = account
|
||||
self.dataFile = DataFile(fileURL: self.fileURL)
|
||||
|
||||
self.dataFile.delegate = self
|
||||
}
|
||||
|
||||
func markAsDirty() {
|
||||
|
||||
dataFile.markAsDirty()
|
||||
}
|
||||
|
||||
func load() {
|
||||
|
||||
if let fileData = try? Data(contentsOf: fileURL) {
|
||||
let decoder = PropertyListDecoder()
|
||||
account.feedMetadata = (try? decoder.decode(Account.FeedMetadataDictionary.self, from: fileData)) ?? Account.FeedMetadataDictionary()
|
||||
}
|
||||
account.feedMetadata.values.forEach { $0.delegate = account }
|
||||
}
|
||||
|
||||
// Save immediately
|
||||
func save() {
|
||||
|
||||
dataFile.save()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedMetadataFile: DataFileDelegate {
|
||||
|
||||
func data(for dataFile: DataFile) -> Data? {
|
||||
|
||||
guard !account.isDeleted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let feedMetadata = metadataForOnlySubscribedToFeeds()
|
||||
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
return try? encoder.encode(feedMetadata)
|
||||
}
|
||||
|
||||
func dataFileWriteToDiskDidFail(for dataFile: DataFile, error: Error) {
|
||||
|
||||
logger.error("FeedMetadataFile save to disk failed for \(self.fileURL): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedMetadataFile {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
211
Modules/Account/Sources/Account/Folder.swift
Normal file
211
Modules/Account/Sources/Account/Folder.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// Folder.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Core
|
||||
|
||||
@MainActor public final class Folder: Renamable, Container, DisplayNameProvider, UnreadCountProvider, Hashable {
|
||||
|
||||
public var containerID: ContainerIdentifier? {
|
||||
guard let accountID = account?.accountID else {
|
||||
assertionFailure("Expected feed.account, but got nil.")
|
||||
return nil
|
||||
}
|
||||
return ContainerIdentifier.folder(accountID, nameForDisplay)
|
||||
}
|
||||
|
||||
public weak var account: Account?
|
||||
public var topLevelFeeds: Set<Feed> = Set<Feed>()
|
||||
public var folders: Set<Folder>? = nil // subfolders are not supported, so this is always nil
|
||||
|
||||
public var name: String? {
|
||||
didSet {
|
||||
postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
static let untitledName = NSLocalizedString("Untitled ƒ", comment: "Folder name")
|
||||
public let folderID: Int // not saved: per-run only
|
||||
public var externalID: String? = nil
|
||||
static var incrementingID = 0
|
||||
|
||||
// MARK: - DisplayNameProvider
|
||||
|
||||
public var nameForDisplay: String {
|
||||
return name ?? Folder.untitledName
|
||||
}
|
||||
|
||||
// MARK: - UnreadCountProvider
|
||||
|
||||
public var unreadCount = 0 {
|
||||
didSet {
|
||||
if unreadCount != oldValue {
|
||||
postUnreadCountDidChangeNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Renamable
|
||||
|
||||
public func rename(to name: String) async throws {
|
||||
|
||||
guard let account else {
|
||||
return
|
||||
}
|
||||
|
||||
try await account.renameFolder(self, to: name)
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(account: Account, name: String?) {
|
||||
self.account = account
|
||||
self.name = name
|
||||
|
||||
let folderID = Folder.incrementingID
|
||||
Folder.incrementingID += 1
|
||||
self.folderID = folderID
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: self)
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@MainActor @objc func unreadCountDidChange(_ note: Notification) {
|
||||
if let object = note.object {
|
||||
if objectIsChild(object as AnyObject) {
|
||||
updateUnreadCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @objc func childrenDidChange(_ note: Notification) {
|
||||
updateUnreadCount()
|
||||
}
|
||||
|
||||
// MARK: Container
|
||||
|
||||
public func flattenedFeeds() -> Set<Feed> {
|
||||
// Since sub-folders are not supported, it’s always the top-level feeds.
|
||||
return topLevelFeeds
|
||||
}
|
||||
|
||||
public func objectIsChild(_ object: AnyObject) -> Bool {
|
||||
// Folders contain Feed objects only, at least for now.
|
||||
guard let feed = object as? Feed else {
|
||||
return false
|
||||
}
|
||||
return topLevelFeeds.contains(feed)
|
||||
}
|
||||
|
||||
public func addFeed(_ feed: Feed) {
|
||||
topLevelFeeds.insert(feed)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
public func addFeeds(_ feeds: Set<Feed>) {
|
||||
guard !feeds.isEmpty else {
|
||||
return
|
||||
}
|
||||
topLevelFeeds.formUnion(feeds)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
public func removeFeed(_ feed: Feed) {
|
||||
topLevelFeeds.remove(feed)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
public func removeFeeds(_ feeds: Set<Feed>) {
|
||||
guard !feeds.isEmpty else {
|
||||
return
|
||||
}
|
||||
topLevelFeeds.subtract(feeds)
|
||||
postChildrenDidChangeNotification()
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
nonisolated public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(folderID)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
nonisolated static public func ==(lhs: Folder, rhs: Folder) -> Bool {
|
||||
return lhs === rhs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension Folder {
|
||||
|
||||
@MainActor func updateUnreadCount() {
|
||||
var updatedUnreadCount = 0
|
||||
for feed in topLevelFeeds {
|
||||
updatedUnreadCount += feed.unreadCount
|
||||
}
|
||||
unreadCount = updatedUnreadCount
|
||||
}
|
||||
|
||||
func childrenContain(_ feed: Feed) -> Bool {
|
||||
return topLevelFeeds.contains(feed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OPMLRepresentable
|
||||
|
||||
extension Folder: OPMLRepresentable {
|
||||
|
||||
public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String {
|
||||
|
||||
let attrExternalID: String = {
|
||||
if allowCustomAttributes, let externalID = externalID {
|
||||
return " nnw_externalID=\"\(externalID.escapingSpecialXMLCharacters)\""
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}()
|
||||
|
||||
let escapedTitle = nameForDisplay.escapingSpecialXMLCharacters
|
||||
var s = "<outline text=\"\(escapedTitle)\" title=\"\(escapedTitle)\"\(attrExternalID)>\n"
|
||||
s = s.prepending(tabCount: indentLevel)
|
||||
|
||||
var hasAtLeastOneChild = false
|
||||
|
||||
for feed in topLevelFeeds.sorted() {
|
||||
s += feed.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes)
|
||||
hasAtLeastOneChild = true
|
||||
}
|
||||
|
||||
if !hasAtLeastOneChild {
|
||||
s = "<outline text=\"\(escapedTitle)\" title=\"\(escapedTitle)\"\(attrExternalID)/>\n"
|
||||
s = s.prepending(tabCount: indentLevel)
|
||||
return s
|
||||
}
|
||||
|
||||
s = s + String(tabCount: indentLevel) + "</outline>\n"
|
||||
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Set
|
||||
|
||||
extension Set where Element == Folder {
|
||||
|
||||
@MainActor func sorted() -> Array<Folder> {
|
||||
return sorted(by: { (folder1, folder2) -> Bool in
|
||||
return folder1.nameForDisplay.localizedStandardCompare(folder2.nameForDisplay) == .orderedAscending
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
129
Modules/Account/Sources/Account/OPMLFile.swift
Normal file
129
Modules/Account/Sources/Account/OPMLFile.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// OPMLFile.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 9/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import Parser
|
||||
import ParserObjC
|
||||
import Core
|
||||
|
||||
@MainActor final class OPMLFile {
|
||||
|
||||
private let fileURL: URL
|
||||
private let account: Account
|
||||
private let dataFile: DataFile
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "OPMLFile")
|
||||
|
||||
init(filename: String, account: Account) {
|
||||
|
||||
self.account = account
|
||||
self.fileURL = URL(fileURLWithPath: filename)
|
||||
self.dataFile = DataFile(fileURL: self.fileURL)
|
||||
|
||||
self.dataFile.delegate = self
|
||||
}
|
||||
|
||||
func markAsDirty() {
|
||||
|
||||
dataFile.markAsDirty()
|
||||
}
|
||||
|
||||
func opmlItems() -> [RSOPMLItem]? {
|
||||
guard let fileData = opmlFileData() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parsedOPMLItems(fileData: fileData)
|
||||
}
|
||||
|
||||
func save() {
|
||||
|
||||
dataFile.save()
|
||||
}
|
||||
}
|
||||
|
||||
private extension OPMLFile {
|
||||
|
||||
func opmlFileData() -> Data? {
|
||||
var fileData: Data? = nil
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
logger.error("OPML read from disk failed for \(self.fileURL): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return fileData
|
||||
}
|
||||
|
||||
func parsedOPMLItems(fileData: Data) -> [RSOPMLItem]? {
|
||||
let parserData = ParserData(url: fileURL.absoluteString, data: fileData)
|
||||
var opmlDocument: RSOPMLDocument?
|
||||
|
||||
do {
|
||||
opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
|
||||
} catch {
|
||||
logger.error("OPML Import failed for \(self.fileURL): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return opmlDocument?.children
|
||||
}
|
||||
|
||||
func opmlDocument() -> String {
|
||||
let escapedTitle = account.nameForDisplay.escapingSpecialXMLCharacters
|
||||
let openingText =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- OPML generated by NetNewsWire -->
|
||||
<opml version="1.1">
|
||||
<head>
|
||||
<title>\(escapedTitle)</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
"""
|
||||
|
||||
let middleText = account.OPMLString(indentLevel: 0, allowCustomAttributes: true)
|
||||
|
||||
let closingText =
|
||||
"""
|
||||
</body>
|
||||
</opml>
|
||||
"""
|
||||
|
||||
let opml = openingText + middleText + closingText
|
||||
return opml
|
||||
}
|
||||
}
|
||||
|
||||
extension OPMLFile: DataFileDelegate {
|
||||
|
||||
func data(for dataFile: DataFile) -> Data? {
|
||||
|
||||
guard !account.isDeleted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let opmlDocumentString = opmlDocument()
|
||||
guard let data = opmlDocumentString.data(using: .utf8, allowLossyConversion: true) else {
|
||||
|
||||
assertionFailure("OPML String conversion to Data failed for \(self.fileURL).")
|
||||
logger.error("OPML String conversion to Data failed for \(self.fileURL)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func dataFileWriteToDiskDidFail(for dataFile: DataFile, error: Error) {
|
||||
|
||||
logger.error("OPML save to disk failed for \(self.fileURL): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
67
Modules/Account/Sources/Account/OPMLNormalizer.swift
Normal file
67
Modules/Account/Sources/Account/OPMLNormalizer.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// OPMLNormalizer.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/31/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import ParserObjC
|
||||
|
||||
final class OPMLNormalizer {
|
||||
|
||||
var normalizedOPMLItems = [RSOPMLItem]()
|
||||
|
||||
static func normalize(_ items: [RSOPMLItem]) -> [RSOPMLItem] {
|
||||
let opmlNormalizer = OPMLNormalizer()
|
||||
opmlNormalizer.normalize(items)
|
||||
return opmlNormalizer.normalizedOPMLItems
|
||||
}
|
||||
|
||||
private func normalize(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) {
|
||||
var feedsToAdd = [RSOPMLItem]()
|
||||
|
||||
for item in items {
|
||||
|
||||
if let _ = item.feedSpecifier {
|
||||
if !feedsToAdd.contains(where: { $0.feedSpecifier?.feedURL == item.feedSpecifier?.feedURL }) {
|
||||
feedsToAdd.append(item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
guard let _ = item.titleFromAttributes else {
|
||||
// Folder doesn’t have a name, so it won’t be created, and its items will go one level up.
|
||||
if let itemChildren = item.children {
|
||||
normalize(itemChildren, parentFolder: parentFolder)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
feedsToAdd.append(item)
|
||||
if let itemChildren = item.children {
|
||||
if let parentFolder = parentFolder {
|
||||
normalize(itemChildren, parentFolder: parentFolder)
|
||||
} else {
|
||||
normalize(itemChildren, parentFolder: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let parentFolder = parentFolder {
|
||||
for feed in feedsToAdd {
|
||||
if !(parentFolder.children?.contains(where: { $0.feedSpecifier?.feedURL == feed.feedSpecifier?.feedURL}) ?? false) {
|
||||
parentFolder.addChild(feed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for feed in feedsToAdd {
|
||||
normalizedOPMLItems.append(feed)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
37
Modules/Account/Sources/Account/SingleArticleFetcher.swift
Normal file
37
Modules/Account/Sources/Account/SingleArticleFetcher.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// SingleArticleFetcher.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 11/29/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
||||
public struct SingleArticleFetcher {
|
||||
|
||||
private let account: Account
|
||||
private let articleID: String
|
||||
|
||||
public init(account: Account, articleID: String) {
|
||||
self.account = account
|
||||
self.articleID = articleID
|
||||
}
|
||||
}
|
||||
|
||||
extension SingleArticleFetcher: ArticleFetcher {
|
||||
|
||||
public func fetchArticles() async throws -> Set<Article> {
|
||||
|
||||
try await account.articles(articleIDs: Set([articleID]))
|
||||
}
|
||||
|
||||
// Doesn’t actually fetch unread articles. Fetches whatever articleID it is asked to fetch.
|
||||
|
||||
public func fetchUnreadArticles() async throws -> Set<Article> {
|
||||
|
||||
try await fetchArticles()
|
||||
}
|
||||
}
|
||||
71
Modules/Account/Sources/Account/URLRequest+Account.swift
Executable file
71
Modules/Account/Sources/Account/URLRequest+Account.swift
Executable file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// URLRequest+Account.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 12/27/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import Secrets
|
||||
import NewsBlur
|
||||
|
||||
public extension URLRequest {
|
||||
|
||||
@MainActor init(url: URL, credentials: Credentials?, conditionalGet: HTTPConditionalGetInfo? = nil) {
|
||||
|
||||
self.init(url: url)
|
||||
|
||||
guard let credentials = credentials else {
|
||||
return
|
||||
}
|
||||
|
||||
switch credentials.type {
|
||||
case .basic:
|
||||
let data = "\(credentials.username):\(credentials.secret)".data(using: .utf8)
|
||||
let base64 = data?.base64EncodedString()
|
||||
let auth = "Basic \(base64 ?? "")"
|
||||
setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
case .newsBlurBasic:
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
httpMethod = "POST"
|
||||
var postData = URLComponents()
|
||||
postData.queryItems = [
|
||||
URLQueryItem(name: "username", value: credentials.username),
|
||||
URLQueryItem(name: "password", value: credentials.secret),
|
||||
]
|
||||
httpBody = postData.enhancedPercentEncodedQuery?.data(using: .utf8)
|
||||
case .newsBlurSessionID:
|
||||
setValue("\(NewsBlurAPICaller.sessionIDCookieKey)=\(credentials.secret)", forHTTPHeaderField: "Cookie")
|
||||
httpShouldHandleCookies = true
|
||||
case .readerBasic:
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
httpMethod = "POST"
|
||||
var postData = URLComponents()
|
||||
postData.queryItems = [
|
||||
URLQueryItem(name: "Email", value: credentials.username),
|
||||
URLQueryItem(name: "Passwd", value: credentials.secret)
|
||||
]
|
||||
httpBody = postData.enhancedPercentEncodedQuery?.data(using: .utf8)
|
||||
case .readerAPIKey:
|
||||
let auth = "GoogleLogin auth=\(credentials.secret)"
|
||||
setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
case .oauthAccessToken:
|
||||
let auth = "OAuth \(credentials.secret)"
|
||||
setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
case .oauthAccessTokenSecret:
|
||||
assertionFailure("Token secrets are used by OAuth1. Did you mean to use `OAuthSwift` instead of a URLRequest?")
|
||||
break
|
||||
case .oauthRefreshToken:
|
||||
// While both access and refresh tokens are credentials, it seems the `Credentials` cases
|
||||
// enumerates how the identity of the user can be proved rather than
|
||||
// credentials-in-general, such as in this refresh token case,
|
||||
// the authority to prove an identity.
|
||||
assertionFailure("Refresh tokens are used to replace expired access tokens. Did you mean to use `accessToken` instead?")
|
||||
break
|
||||
}
|
||||
|
||||
conditionalGet?.addRequestHeadersToURLRequest(&self)
|
||||
}
|
||||
}
|
||||
47
Modules/Account/Sources/Account/UnreadCountProvider.swift
Normal file
47
Modules/Account/Sources/Account/UnreadCountProvider.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// UnreadCountProtocol.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 4/8/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
static let UnreadCountDidInitialize = Notification.Name("UnreadCountDidInitialize")
|
||||
static let UnreadCountDidChange = Notification.Name(rawValue: "UnreadCountDidChange")
|
||||
}
|
||||
|
||||
public protocol UnreadCountProvider {
|
||||
|
||||
@MainActor var unreadCount: Int { get }
|
||||
|
||||
@MainActor func postUnreadCountDidChangeNotification()
|
||||
@MainActor func calculateUnreadCount<T: Collection>(_ children: T) -> Int
|
||||
}
|
||||
|
||||
|
||||
public extension UnreadCountProvider {
|
||||
|
||||
@MainActor func postUnreadCountDidInitializeNotification() {
|
||||
NotificationCenter.default.post(name: .UnreadCountDidInitialize, object: self, userInfo: nil)
|
||||
}
|
||||
|
||||
@MainActor func postUnreadCountDidChangeNotification() {
|
||||
NotificationCenter.default.post(name: .UnreadCountDidChange, object: self, userInfo: nil)
|
||||
}
|
||||
|
||||
@MainActor func calculateUnreadCount<T: Collection>(_ children: T) -> Int {
|
||||
let updatedUnreadCount = children.reduce(0) { (result, oneChild) -> Int in
|
||||
if let oneUnreadCountProvider = oneChild as? UnreadCountProvider {
|
||||
return result + oneUnreadCountProvider.unreadCount
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return updatedUnreadCount
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
Modules/Account/Tests/AccountTests/AccountCredentialsTest.swift
Normal file
101
Modules/Account/Tests/AccountTests/AccountCredentialsTest.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// AccountCredentialsTest.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/4/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Web
|
||||
@testable import Account
|
||||
import Secrets
|
||||
|
||||
//class AccountCredentialsTest: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
//
|
||||
// override func setUp() {
|
||||
// account = TestAccountManager.shared.createAccount(type: .feedbin, transport: TestTransport())
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// TestAccountManager.shared.deleteAccount(account)
|
||||
// }
|
||||
//
|
||||
// func testCreateRetrieveDelete() {
|
||||
//
|
||||
// // Make sure any left over from failed tests are gone
|
||||
// do {
|
||||
// try account.removeCredentials(type: .basic)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// var credentials: Credentials? = Credentials(type: .basic, username: "maurice", secret: "hardpasswd")
|
||||
//
|
||||
// // Store the credentials
|
||||
// do {
|
||||
// try account.storeCredentials(credentials!)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// // Retrieve them
|
||||
// credentials = nil
|
||||
// do {
|
||||
// credentials = try account.retrieveCredentials(type: .basic)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// switch credentials!.type {
|
||||
// case .basic:
|
||||
// XCTAssertEqual("maurice", credentials?.username)
|
||||
// XCTAssertEqual("hardpasswd", credentials?.secret)
|
||||
// default:
|
||||
// XCTFail("Expected \(CredentialsType.basic), received \(credentials!.type)")
|
||||
// }
|
||||
//
|
||||
// // Update them
|
||||
// credentials = Credentials(type: .basic, username: "maurice", secret: "easypasswd")
|
||||
// do {
|
||||
// try account.storeCredentials(credentials!)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// // Retrieve them again
|
||||
// credentials = nil
|
||||
// do {
|
||||
// credentials = try account.retrieveCredentials(type: .basic)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// switch credentials!.type {
|
||||
// case .basic:
|
||||
// XCTAssertEqual("maurice", credentials?.username)
|
||||
// XCTAssertEqual("easypasswd", credentials?.secret)
|
||||
// default:
|
||||
// XCTFail("Expected \(CredentialsType.basic), received \(credentials!.type)")
|
||||
// }
|
||||
//
|
||||
// // Delete them
|
||||
// do {
|
||||
// try account.removeCredentials(type: .basic)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// // Make sure they are gone
|
||||
// do {
|
||||
// try credentials = account.retrieveCredentials(type: .basic)
|
||||
// } catch {
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
//
|
||||
// XCTAssertNil(credentials)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// AccountFeedbinFolderContentsSyncTest.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/7/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class AccountFeedbinFolderContentsSyncTest: XCTestCase {
|
||||
//
|
||||
// override func setUp() {
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// }
|
||||
//
|
||||
// func testDownloadSync() {
|
||||
//
|
||||
// let testTransport = TestTransport()
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "JSON/tags_add.json"
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/subscriptions.json"] = "JSON/subscriptions_initial.json"
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "JSON/taggings_initial.json"
|
||||
// let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
|
||||
//
|
||||
// // Test initial folders
|
||||
// let initialExpection = self.expectation(description: "Initial contents")
|
||||
// account.refreshAll() { _ in
|
||||
// initialExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// let folder = account.folders?.filter { $0.name == "Developers" } .first!
|
||||
// XCTAssertEqual(156, folder?.topLevelFeeds.count ?? 0)
|
||||
// XCTAssertEqual(2, account.topLevelFeeds.count)
|
||||
//
|
||||
// // Test Adding a Feed to the folder
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "JSON/taggings_add.json"
|
||||
//
|
||||
// let addExpection = self.expectation(description: "Add contents")
|
||||
// account.refreshAll() { _ in
|
||||
// addExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// XCTAssertEqual(157, folder?.topLevelFeeds.count ?? 0)
|
||||
// XCTAssertEqual(1, account.topLevelFeeds.count)
|
||||
//
|
||||
// // Test Deleting some Feeds from the folder
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/taggings.json"] = "JSON/taggings_delete.json"
|
||||
//
|
||||
// let deleteExpection = self.expectation(description: "Delete contents")
|
||||
// account.refreshAll() { _ in
|
||||
// deleteExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// XCTAssertEqual(153, folder?.topLevelFeeds.count ?? 0)
|
||||
// XCTAssertEqual(5, account.topLevelFeeds.count)
|
||||
//
|
||||
// TestAccountManager.shared.deleteAccount(account)
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// AccountFeedbinFolderSyncTest.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/5/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class AccountFeedbinFolderSyncTest: XCTestCase {
|
||||
//
|
||||
// override func setUp() {
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// }
|
||||
//
|
||||
// func testDownloadSync() {
|
||||
//
|
||||
// let testTransport = TestTransport()
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "JSON/tags_initial.json"
|
||||
// let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
|
||||
//
|
||||
// // Test initial folders
|
||||
// let initialExpection = self.expectation(description: "Initial tags")
|
||||
// account.refreshAll() { _ in
|
||||
// initialExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// guard let intialFolders = account.folders else {
|
||||
// XCTFail()
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(9, intialFolders.count)
|
||||
// let initialFolderNames = intialFolders.map { $0.name ?? "" }
|
||||
// XCTAssertTrue(initialFolderNames.contains("Outdoors"))
|
||||
//
|
||||
// // Test removing folders
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "JSON/tags_delete.json"
|
||||
//
|
||||
// let deleteExpection = self.expectation(description: "Delete tags")
|
||||
// account.refreshAll() { _ in
|
||||
// deleteExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// guard let deleteFolders = account.folders else {
|
||||
// XCTFail()
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(8, deleteFolders.count)
|
||||
// let deleteFolderNames = deleteFolders.map { $0.name ?? "" }
|
||||
// XCTAssertTrue(deleteFolderNames.contains("Outdoors"))
|
||||
// XCTAssertFalse(deleteFolderNames.contains("Tech Media"))
|
||||
//
|
||||
// // Test Adding Folders
|
||||
// testTransport.testFiles["https://api.feedbin.com/v2/tags.json"] = "JSON/tags_add.json"
|
||||
//
|
||||
// let addExpection = self.expectation(description: "Add tags")
|
||||
// account.refreshAll() { _ in
|
||||
// addExpection.fulfill()
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// guard let addFolders = account.folders else {
|
||||
// XCTFail()
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(10, addFolders.count)
|
||||
// let addFolderNames = addFolders.map { $0.name ?? "" }
|
||||
// XCTAssertTrue(addFolderNames.contains("Vanlife"))
|
||||
//
|
||||
// TestAccountManager.shared.deleteAccount(account)
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// AccountFeedbinSyncTest.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/6/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class AccountFeedbinSyncTest: XCTestCase {
|
||||
//
|
||||
// override func setUp() {
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// }
|
||||
//
|
||||
// func testDownloadSync() {
|
||||
//
|
||||
// let testTransport = TestTransport()
|
||||
// testTransport.testFiles["tags.json"] = "JSON/tags_add.json"
|
||||
// testTransport.testFiles["subscriptions.json"] = "JSON/subscriptions_initial.json"
|
||||
// let account = TestAccountManager.shared.createAccount(type: .feedbin, transport: testTransport)
|
||||
//
|
||||
// // Test initial folders
|
||||
// let initialExpection = self.expectation(description: "Initial feeds")
|
||||
// account.refreshAll() { result in
|
||||
// switch result {
|
||||
// case .success:
|
||||
// initialExpection.fulfill()
|
||||
// case .failure(let error):
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// XCTAssertEqual(224, account.flattenedFeeds().count)
|
||||
//
|
||||
// let daringFireball = account.idToFeedDictionary["1296379"]
|
||||
// XCTAssertEqual("Daring Fireball", daringFireball!.name)
|
||||
// XCTAssertEqual("https://daringfireball.net/feeds/json", daringFireball!.url)
|
||||
// XCTAssertEqual("https://daringfireball.net/", daringFireball!.homePageURL)
|
||||
//
|
||||
// // Test Adding a Feed
|
||||
// testTransport.testFiles["subscriptions.json"] = "JSON/subscriptions_add.json"
|
||||
//
|
||||
// let addExpection = self.expectation(description: "Add feeds")
|
||||
// account.refreshAll() { result in
|
||||
// switch result {
|
||||
// case .success:
|
||||
// addExpection.fulfill()
|
||||
// case .failure(let error):
|
||||
// XCTFail(error.localizedDescription)
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 5, handler: nil)
|
||||
//
|
||||
// XCTAssertEqual(225, account.flattenedFeeds().count)
|
||||
//
|
||||
// let bPixels = account.idToFeedDictionary["1096623"]
|
||||
// XCTAssertEqual("Beautiful Pixels", bPixels?.name)
|
||||
// XCTAssertEqual("https://feedpress.me/beautifulpixels", bPixels?.url)
|
||||
// XCTAssertEqual("https://beautifulpixels.com/", bPixels?.homePageURL)
|
||||
//
|
||||
// TestAccountManager.shared.deleteAccount(account)
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// FeedlyCreateFeedsForCollectionFoldersOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 22/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// class FeedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding {
|
||||
// var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
// }
|
||||
//
|
||||
// func testAddFeeds() {
|
||||
// let feedsForFolderOne = [
|
||||
// FeedlyFeed(id: "feed/1", title: "Feed One", updated: nil, website: nil),
|
||||
// FeedlyFeed(id: "feed/2", title: "Feed Two", updated: nil, website: nil)
|
||||
// ]
|
||||
//
|
||||
// let feedsForFolderTwo = [
|
||||
// FeedlyFeed(id: "feed/1", title: "Feed One", updated: nil, website: nil),
|
||||
// FeedlyFeed(id: "feed/3", title: "Feed Three", updated: nil, website: nil),
|
||||
// ]
|
||||
//
|
||||
// let folderOne: (name: String, id: String) = ("FolderOne", "folder/1")
|
||||
// let folderTwo: (name: String, id: String) = ("FolderTwo", "folder/2")
|
||||
// let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
|
||||
//
|
||||
// let provider = FeedsAndFoldersProvider()
|
||||
// provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
|
||||
// let accountFolder = account.ensureFolder(with: folder.name)!
|
||||
// accountFolder.externalID = folder.id
|
||||
// return (feeds, accountFolder)
|
||||
// }
|
||||
//
|
||||
// let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// createFeeds.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// XCTAssertTrue(account.flattenedFeeds().isEmpty, "Expected empty account.")
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(createFeeds)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let feedIDs = Set([feedsForFolderOne, feedsForFolderTwo]
|
||||
// .flatMap { $0 }
|
||||
// .map { $0.id })
|
||||
//
|
||||
// let feedTitles = Set([feedsForFolderOne, feedsForFolderTwo]
|
||||
// .flatMap { $0 }
|
||||
// .map { $0.title })
|
||||
//
|
||||
// let accountFeeds = account.flattenedFeeds()
|
||||
// let ingestedIDs = Set(accountFeeds.map { $0.feedID })
|
||||
// let ingestedTitles = Set(accountFeeds.map { $0.nameForDisplay })
|
||||
//
|
||||
// let missingIDs = feedIDs.subtracting(ingestedIDs)
|
||||
// let missingTitles = feedTitles.subtracting(ingestedTitles)
|
||||
//
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Failed to ingest feeds with these ids.")
|
||||
// XCTAssertTrue(missingTitles.isEmpty, "Failed to ingest feeds with these titles.")
|
||||
//
|
||||
// let expectedFolderAndFeedIDs = namesAndFeeds
|
||||
// .sorted { $0.0.id < $1.0.id }
|
||||
// .map { folder, feeds -> [String: [String]] in
|
||||
// return [folder.id: feeds.map { $0.id }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// let ingestedFolderAndFeedIDs = (account.folders ?? Set())
|
||||
// .sorted { $0.externalID! < $1.externalID! }
|
||||
// .compactMap { folder -> [String: [String]]? in
|
||||
// return [folder.externalID!: folder.topLevelFeeds.map { $0.feedID }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(expectedFolderAndFeedIDs, ingestedFolderAndFeedIDs, "Did not ingest feeds in their corresponding folders.")
|
||||
// }
|
||||
//
|
||||
// func testRemoveFeeds() {
|
||||
// let folderOne: (name: String, id: String) = ("FolderOne", "folder/1")
|
||||
// let folderTwo: (name: String, id: String) = ("FolderTwo", "folder/2")
|
||||
// let feedToRemove = FeedlyFeed(id: "feed/1", title: "Feed One", updated: nil, website: nil)
|
||||
//
|
||||
// var feedsForFolderOne = [
|
||||
// feedToRemove,
|
||||
// FeedlyFeed(id: "feed/2", title: "Feed Two", updated: nil, website: nil)
|
||||
// ]
|
||||
//
|
||||
// var feedsForFolderTwo = [
|
||||
// feedToRemove,
|
||||
// FeedlyFeed(id: "feed/3", title: "Feed Three", updated: nil, website: nil),
|
||||
// ]
|
||||
//
|
||||
// // Add initial content.
|
||||
// do {
|
||||
// let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
|
||||
//
|
||||
// let provider = FeedsAndFoldersProvider()
|
||||
// provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
|
||||
// let accountFolder = account.ensureFolder(with: folder.name)!
|
||||
// accountFolder.externalID = folder.id
|
||||
// return (feeds, accountFolder)
|
||||
// }
|
||||
//
|
||||
// let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// createFeeds.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// XCTAssertTrue(account.flattenedFeeds().isEmpty, "Expected empty account.")
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(createFeeds)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// feedsForFolderOne.removeAll { $0.id == feedToRemove.id }
|
||||
// feedsForFolderTwo.removeAll { $0.id == feedToRemove.id }
|
||||
// let namesAndFeeds = [(folderOne, feedsForFolderOne), (folderTwo, feedsForFolderTwo)]
|
||||
//
|
||||
// let provider = FeedsAndFoldersProvider()
|
||||
// provider.feedsAndFolders = namesAndFeeds.map { (folder, feeds) in
|
||||
// let accountFolder = account.ensureFolder(with: folder.name)!
|
||||
// accountFolder.externalID = folder.id
|
||||
// return (feeds, accountFolder)
|
||||
// }
|
||||
//
|
||||
// let removeFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// removeFeeds.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(removeFeeds)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let feedIDs = Set([feedsForFolderOne, feedsForFolderTwo]
|
||||
// .flatMap { $0 }
|
||||
// .map { $0.id })
|
||||
//
|
||||
// let feedTitles = Set([feedsForFolderOne, feedsForFolderTwo]
|
||||
// .flatMap { $0 }
|
||||
// .map { $0.title })
|
||||
//
|
||||
// let accountFeeds = account.flattenedFeeds()
|
||||
// let ingestedIDs = Set(accountFeeds.map { $0.feedID })
|
||||
// let ingestedTitles = Set(accountFeeds.map { $0.nameForDisplay })
|
||||
//
|
||||
// XCTAssertEqual(ingestedIDs.count, feedIDs.count)
|
||||
// XCTAssertEqual(ingestedTitles.count, feedTitles.count)
|
||||
//
|
||||
// let missingIDs = feedIDs.subtracting(ingestedIDs)
|
||||
// let missingTitles = feedTitles.subtracting(ingestedTitles)
|
||||
//
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Failed to ingest feeds with these ids.")
|
||||
// XCTAssertTrue(missingTitles.isEmpty, "Failed to ingest feeds with these titles.")
|
||||
//
|
||||
// let expectedFolderAndFeedIDs = namesAndFeeds
|
||||
// .sorted { $0.0.id < $1.0.id }
|
||||
// .map { folder, feeds -> [String: [String]] in
|
||||
// return [folder.id: feeds.map { $0.id }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// let ingestedFolderAndFeedIDs = (account.folders ?? Set())
|
||||
// .sorted { $0.externalID! < $1.externalID! }
|
||||
// .compactMap { folder -> [String: [String]]? in
|
||||
// return [folder.externalID!: folder.topLevelFeeds.map { $0.feedID }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(expectedFolderAndFeedIDs, ingestedFolderAndFeedIDs, "Did not ingest feeds to their corresponding folders.")
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// FeedlyGetCollectionsOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 21/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import os.log
|
||||
|
||||
//class FeedlyGetCollectionsOperationTests: XCTestCase {
|
||||
//
|
||||
// func testGetCollections() {
|
||||
// let support = FeedlyTestSupport()
|
||||
// let (transport, caller) = support.makeMockNetworkStack()
|
||||
// let jsonName = "JSON/feedly_collections_initial"
|
||||
// transport.testFiles["/v3/collections"] = "\(jsonName).json"
|
||||
//
|
||||
// let getCollections = FeedlyGetCollectionsOperation(service: caller, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// getCollections.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(getCollections)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let collections = support.testJSON(named: jsonName) as! [[String:Any]]
|
||||
// let labelsInJSON = Set(collections.map { $0["label"] as! String })
|
||||
// let idsInJSON = Set(collections.map { $0["id"] as! String })
|
||||
//
|
||||
// let labels = Set(getCollections.collections.map { $0.label })
|
||||
// let ids = Set(getCollections.collections.map { $0.id })
|
||||
//
|
||||
// let missingLabels = labelsInJSON.subtracting(labels)
|
||||
// let missingIDs = idsInJSON.subtracting(ids)
|
||||
//
|
||||
// XCTAssertEqual(getCollections.collections.count, collections.count, "Mismatch between collections provided by operation and test JSON collections.")
|
||||
// XCTAssertTrue(missingLabels.isEmpty, "Collections with these labels did not have a corresponding \(FeedlyCollection.self) value with the same name.")
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Collections with these ids did not have a corresponding \(FeedlyCollection.self) with the same id.")
|
||||
//
|
||||
// for collection in collections {
|
||||
// let collectionID = collection["id"] as! String
|
||||
// let collectionFeeds = collection["feeds"] as! [[String: Any]]
|
||||
// let collectionFeedIDs = Set(collectionFeeds.map { $0["id"] as! String })
|
||||
//
|
||||
// for operationCollection in getCollections.collections where operationCollection.id == collectionID {
|
||||
// let feedIDs = Set(operationCollection.feeds.map { $0.id })
|
||||
// let missingIDs = collectionFeedIDs.subtracting(feedIDs)
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Feeds with these ids were not found in the \"\(operationCollection.label)\" \(FeedlyCollection.self).")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testGetCollectionsError() {
|
||||
//
|
||||
// class TestDelegate: FeedlyOperationDelegate {
|
||||
// var errorExpectation: XCTestExpectation?
|
||||
// var error: Error?
|
||||
//
|
||||
// func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
// self.error = error
|
||||
// errorExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// let delegate = TestDelegate()
|
||||
// delegate.errorExpectation = expectation(description: "Did Fail With Expected Error")
|
||||
//
|
||||
// let support = FeedlyTestSupport()
|
||||
// let service = TestGetCollectionsService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
//
|
||||
// let getCollections = FeedlyGetCollectionsOperation(service: service, log: support.log)
|
||||
// getCollections.delegate = delegate
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// getCollections.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(getCollections)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// XCTAssertNotNil(delegate.error)
|
||||
// XCTAssertTrue(getCollections.collections.isEmpty, "Collections should be empty.")
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// FeedlyGetStreamContentsOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 23/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class FeedlyGetStreamContentsOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// func testGetStreamContentsFailure() {
|
||||
// let service = TestGetStreamContentsService()
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/1234/category/5678")
|
||||
//
|
||||
// let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: service, continuation: nil, newerThan: nil, unreadOnly: nil, log: support.log)
|
||||
//
|
||||
// service.mockResult = .failure(URLError(.fileDoesNotExist))
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// getStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(getStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// XCTAssertNil(getStreamContents.stream)
|
||||
// }
|
||||
//
|
||||
// func testValuesPassingForGetStreamContents() {
|
||||
// let service = TestGetStreamContentsService()
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/1234/category/5678")
|
||||
//
|
||||
// let continuation: String? = "abcdefg"
|
||||
// let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 86)
|
||||
// let unreadOnly: Bool? = true
|
||||
//
|
||||
// let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: service, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly, log: support.log)
|
||||
//
|
||||
// let mockStream = FeedlyStream(id: "stream/1", updated: nil, continuation: nil, items: [])
|
||||
// service.mockResult = .success(mockStream)
|
||||
// service.getStreamContentsExpectation = expectation(description: "Did Call Service")
|
||||
// service.parameterTester = { serviceResource, serviceContinuation, serviceNewerThan, serviceUnreadOnly in
|
||||
// // Verify these values given to the operation are passed to the service.
|
||||
// XCTAssertEqual(serviceResource.id, resource.id)
|
||||
// XCTAssertEqual(serviceContinuation, continuation)
|
||||
// XCTAssertEqual(serviceNewerThan, newerThan)
|
||||
// XCTAssertEqual(serviceUnreadOnly, unreadOnly)
|
||||
// }
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// getStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(getStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// guard let stream = getStreamContents.stream else {
|
||||
// XCTFail("\(FeedlyGetStreamContentsOperation.self) did not store the stream.")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(stream.id, mockStream.id)
|
||||
// XCTAssertEqual(stream.updated, mockStream.updated)
|
||||
// XCTAssertEqual(stream.continuation, mockStream.continuation)
|
||||
//
|
||||
// let streamIDs = stream.items.map { $0.id }
|
||||
// let mockStreamIDs = mockStream.items.map { $0.id }
|
||||
// XCTAssertEqual(streamIDs, mockStreamIDs)
|
||||
// }
|
||||
//
|
||||
// func testGetStreamContentsFromJSON() {
|
||||
// let support = FeedlyTestSupport()
|
||||
// let (transport, caller) = support.makeMockNetworkStack()
|
||||
// let jsonName = "JSON/feedly_macintosh_initial"
|
||||
// transport.testFiles["/v3/streams/contents"] = "\(jsonName).json"
|
||||
//
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category/5ca4d61d-e55d-4999-a8d1-c3b9d8789815")
|
||||
// let getStreamContents = FeedlyGetStreamContentsOperation(account: account, resource: resource, service: caller, continuation: nil, newerThan: nil, unreadOnly: nil, log: support.log)
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// getStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(getStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// // verify entry providing and parsed item providing
|
||||
// guard let stream = getStreamContents.stream else {
|
||||
// return XCTFail("Expected to have stream.")
|
||||
// }
|
||||
//
|
||||
// let streamJSON = support.testJSON(named: jsonName) as! [String:Any]
|
||||
//
|
||||
// let id = streamJSON["id"] as! String
|
||||
// XCTAssertEqual(stream.id, id)
|
||||
//
|
||||
// let milliseconds = streamJSON["updated"] as! Double
|
||||
// let updated = Date(timeIntervalSince1970: TimeInterval(milliseconds / 1000))
|
||||
// XCTAssertEqual(stream.updated, updated)
|
||||
//
|
||||
// let continuation = streamJSON["continuation"] as! String
|
||||
// XCTAssertEqual(stream.continuation, continuation)
|
||||
//
|
||||
// support.check(getStreamContents.entries, correspondToStreamItemsIn: streamJSON)
|
||||
// support.check(stream.items, correspondToStreamItemsIn: streamJSON)
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// FeedlyLogoutOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 15/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import Secrets
|
||||
|
||||
//class FeedlyLogoutOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// private func getTokens(for account: Account) throws -> (accessToken: Credentials, refreshToken: Credentials) {
|
||||
// guard let accessToken = try account.retrieveCredentials(type: .oauthAccessToken), let refreshToken = try account.retrieveCredentials(type: .oauthRefreshToken) else {
|
||||
// XCTFail("Unable to retrieve access and/or refresh token from account.")
|
||||
// throw CredentialsError.incompleteCredentials
|
||||
// }
|
||||
// return (accessToken, refreshToken)
|
||||
// }
|
||||
//
|
||||
// class TestFeedlyLogoutService: FeedlyLogoutService {
|
||||
// var mockResult: Result<Void, Error>?
|
||||
// var logoutExpectation: XCTestExpectation?
|
||||
//
|
||||
// func logout(completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
// guard let result = mockResult else {
|
||||
// XCTFail("Missing mock result. Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// DispatchQueue.main.async {
|
||||
// completion(result)
|
||||
// self.logoutExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLogoutSuccess() {
|
||||
// let service = TestFeedlyLogoutService()
|
||||
// service.logoutExpectation = expectation(description: "Did Call Logout")
|
||||
// service.mockResult = .success(())
|
||||
//
|
||||
// let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
|
||||
//
|
||||
// // If this expectation is not fulfilled, the operation is not calling `didFinish`.
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// logout.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(logout)
|
||||
//
|
||||
// waitForExpectations(timeout: 1)
|
||||
//
|
||||
// XCTAssertFalse(logout.isCanceled)
|
||||
//
|
||||
// do {
|
||||
// let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
|
||||
// let accountRefreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
|
||||
//
|
||||
// XCTAssertNil(accountAccessToken)
|
||||
// XCTAssertNil(accountRefreshToken)
|
||||
// } catch {
|
||||
// XCTFail("Could not verify tokens were deleted.")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// class TestLogoutDelegate: FeedlyOperationDelegate {
|
||||
// var error: Error?
|
||||
// var didFailExpectation: XCTestExpectation?
|
||||
//
|
||||
// func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
// self.error = error
|
||||
// didFailExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLogoutMissingAccessToken() {
|
||||
// support.removeCredentials(matching: .oauthAccessToken, from: account)
|
||||
//
|
||||
// let (_, service) = support.makeMockNetworkStack()
|
||||
// service.credentials = nil
|
||||
//
|
||||
// let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
|
||||
//
|
||||
// let delegate = TestLogoutDelegate()
|
||||
// delegate.didFailExpectation = expectation(description: "Did Fail")
|
||||
//
|
||||
// logout.delegate = delegate
|
||||
//
|
||||
// // If this expectation is not fulfilled, the operation is not calling `didFinish`.
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// logout.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(logout)
|
||||
//
|
||||
// waitForExpectations(timeout: 1)
|
||||
//
|
||||
// XCTAssertFalse(logout.isCanceled)
|
||||
//
|
||||
// do {
|
||||
// let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
|
||||
// XCTAssertNil(accountAccessToken)
|
||||
// } catch {
|
||||
// XCTFail("Could not verify tokens were deleted.")
|
||||
// }
|
||||
//
|
||||
// XCTAssertNotNil(delegate.error, "Should have failed with error.")
|
||||
// if let error = delegate.error {
|
||||
// switch error {
|
||||
// case CredentialsError.incompleteCredentials:
|
||||
// break
|
||||
// default:
|
||||
// XCTFail("Expected \(CredentialsError.incompleteCredentials)")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testLogoutFailure() {
|
||||
// let service = TestFeedlyLogoutService()
|
||||
// service.logoutExpectation = expectation(description: "Did Call Logout")
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
//
|
||||
// let accessToken: Credentials
|
||||
// let refreshToken: Credentials
|
||||
// do {
|
||||
// (accessToken, refreshToken) = try getTokens(for: account)
|
||||
// } catch {
|
||||
// XCTFail("Could not retrieve credentials to verify their integrity later.")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let logout = FeedlyLogoutOperation(account: account, service: service, log: support.log)
|
||||
//
|
||||
// // If this expectation is not fulfilled, the operation is not calling `didFinish`.
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// logout.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(logout)
|
||||
//
|
||||
// waitForExpectations(timeout: 1)
|
||||
//
|
||||
// XCTAssertFalse(logout.isCanceled)
|
||||
//
|
||||
// do {
|
||||
// let accountAccessToken = try account.retrieveCredentials(type: .oauthAccessToken)
|
||||
// let accountRefreshToken = try account.retrieveCredentials(type: .oauthRefreshToken)
|
||||
//
|
||||
// XCTAssertEqual(accountAccessToken, accessToken)
|
||||
// XCTAssertEqual(accountRefreshToken, refreshToken)
|
||||
// } catch {
|
||||
// XCTFail("Could not verify tokens were left intact. Did the operation delete them?")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// FeedlyMirrorCollectionsAsFoldersOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 22/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// class CollectionsProvider: FeedlyCollectionProviding {
|
||||
// var collections = [
|
||||
// FeedlyCollection(feeds: [], label: "One", id: "collections/1"),
|
||||
// FeedlyCollection(feeds: [], label: "Two", id: "collections/2")
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// func testAddsFolders() {
|
||||
// let provider = CollectionsProvider()
|
||||
// let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// mirrorOperation.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(mirrorOperation)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let folders = account.folders ?? Set()
|
||||
// let folderNames = Set(folders.compactMap { $0.nameForDisplay })
|
||||
// let folderExternalIDs = Set(folders.compactMap { $0.externalID })
|
||||
//
|
||||
// let collectionLabels = Set(provider.collections.map { $0.label })
|
||||
// let collectionIDs = Set(provider.collections.map { $0.id })
|
||||
//
|
||||
// let missingNames = collectionLabels.subtracting(folderNames)
|
||||
// let missingIDs = collectionIDs.subtracting(folderExternalIDs)
|
||||
//
|
||||
// XCTAssertTrue(missingNames.isEmpty, "Collections with these labels have no corresponding folder.")
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Collections with these ids have no corresponding folder.")
|
||||
//// XCTAssertEqual(mirrorOperation.collectionsAndFolders.count, provider.collections.count, "Mismatch between collections and folders.")
|
||||
// }
|
||||
//
|
||||
// func testRemovesFolders() {
|
||||
// let provider = CollectionsProvider()
|
||||
//
|
||||
// do {
|
||||
// let addFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// addFolders.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(addFolders)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// // Now that the folders are added, remove them all.
|
||||
// provider.collections = []
|
||||
//
|
||||
// let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// removeFolders.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(removeFolders)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let folders = account.folders ?? Set()
|
||||
// let folderNames = Set(folders.compactMap { $0.nameForDisplay })
|
||||
// let folderExternalIDs = Set(folders.compactMap { $0.externalID })
|
||||
//
|
||||
// let collectionLabels = Set(provider.collections.map { $0.label })
|
||||
// let collectionIDs = Set(provider.collections.map { $0.id })
|
||||
//
|
||||
// let remainingNames = folderNames.subtracting(collectionLabels)
|
||||
// let remainingIDs = folderExternalIDs.subtracting(collectionIDs)
|
||||
//
|
||||
// XCTAssertTrue(remainingNames.isEmpty, "Folders with these names remain with no corresponding collection.")
|
||||
// XCTAssertTrue(remainingIDs.isEmpty, "Folders with these ids remain with no corresponding collection.")
|
||||
//
|
||||
// XCTAssertTrue(removeFolders.feedsAndFolders.isEmpty)
|
||||
// }
|
||||
//
|
||||
// class CollectionsAndFeedsProvider: FeedlyCollectionProviding {
|
||||
// var feedsForCollectionOne = [
|
||||
// FeedlyFeed(id: "feed/1", title: "Feed One", updated: nil, website: nil),
|
||||
// FeedlyFeed(id: "feed/2", title: "Feed Two", updated: nil, website: nil)
|
||||
// ]
|
||||
//
|
||||
// var feedsForCollectionTwo = [
|
||||
// FeedlyFeed(id: "feed/1", title: "Feed One", updated: nil, website: nil),
|
||||
// FeedlyFeed(id: "feed/3", title: "Feed Three", updated: nil, website: nil),
|
||||
// ]
|
||||
//
|
||||
// var collections: [FeedlyCollection] {
|
||||
// return [
|
||||
// FeedlyCollection(feeds: feedsForCollectionOne, label: "One", id: "collections/1"),
|
||||
// FeedlyCollection(feeds: feedsForCollectionTwo, label: "Two", id: "collections/2")
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testFeedMappedToFolders() {
|
||||
// let provider = CollectionsAndFeedsProvider()
|
||||
// let mirrorOperation = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// mirrorOperation.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(mirrorOperation)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let folders = account.folders ?? Set()
|
||||
// let folderNames = Set(folders.compactMap { $0.nameForDisplay })
|
||||
// let folderExternalIDs = Set(folders.compactMap { $0.externalID })
|
||||
//
|
||||
// let collectionLabels = Set(provider.collections.map { $0.label })
|
||||
// let collectionIDs = Set(provider.collections.map { $0.id })
|
||||
//
|
||||
// let missingNames = collectionLabels.subtracting(folderNames)
|
||||
// let missingIDs = collectionIDs.subtracting(folderExternalIDs)
|
||||
//
|
||||
// XCTAssertTrue(missingNames.isEmpty, "Collections with these labels have no corresponding folder.")
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Collections with these ids have no corresponding folder.")
|
||||
//
|
||||
// let collectionIDsAndFeedIDs = provider.collections.map { collection -> [String:[String]] in
|
||||
// return [collection.id: collection.feeds.map { $0.id }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// let folderIDsAndFeedIDs = mirrorOperation.feedsAndFolders.compactMap { feeds, folder -> [String:[String]]? in
|
||||
// guard let id = folder.externalID else {
|
||||
// return nil
|
||||
// }
|
||||
// return [id: feeds.map { $0.id }.sorted(by: <)]
|
||||
// }
|
||||
//
|
||||
// XCTAssertEqual(collectionIDsAndFeedIDs, folderIDsAndFeedIDs, "Did not map folders to feeds correctly.")
|
||||
// }
|
||||
//
|
||||
// func testRemovingFolderRemovesFeeds() {
|
||||
// do {
|
||||
// let provider = CollectionsAndFeedsProvider()
|
||||
// let addFoldersAndFeeds = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
//
|
||||
// let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addFoldersAndFeeds, log: support.log)
|
||||
// MainThreadOperationQueue.shared.make(createFeeds, dependOn: addFoldersAndFeeds)
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// createFeeds.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.addOperations([addFoldersAndFeeds, createFeeds])
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// XCTAssertFalse(account.flattenedFeeds().isEmpty, "Expected account to have feeds.")
|
||||
// }
|
||||
//
|
||||
// // Now that the folders are added, remove them all.
|
||||
// let provider = CollectionsProvider()
|
||||
// provider.collections = []
|
||||
//
|
||||
// let removeFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: provider, log: support.log)
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// removeFolders.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(removeFolders)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let feeds = account.flattenedFeeds()
|
||||
//
|
||||
// XCTAssertTrue(feeds.isEmpty)
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,512 @@
|
||||
//
|
||||
// FeedlySendArticleStatusesOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 25/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import SyncDatabase
|
||||
import Articles
|
||||
|
||||
//class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
// private var container: FeedlyTestSupport.TestDatabaseContainer!
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// container = support.makeTestDatabaseContainer()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// container = nil
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// func testSendEmpty() {
|
||||
// let service = TestMarkArticlesService()
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendUnreadSuccess() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .read, flag: false) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .success(())
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .unread)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, 0)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendUnreadFailure() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .read, flag: false) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .unread)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, statuses.count)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendReadSuccess() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .read, flag: true) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .success(())
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .read)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, 0)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendReadFailure() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .read, flag: true) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .read)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, statuses.count)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendStarredSuccess() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .success(())
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .saved)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, 0)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendStarredFailure() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .saved)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, statuses.count)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendUnstarredSuccess() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .success(())
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .unsaved)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, 0)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendUnstarredFailure() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let statuses = articleIDs.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// XCTAssertEqual(serviceArticleIDs, articleIDs)
|
||||
// XCTAssertEqual(action, .unsaved)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let expectedCount = try result.get()
|
||||
// XCTAssertEqual(expectedCount, statuses.count)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendAllSuccess() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let keys = [SyncStatus.Key.read, .starred]
|
||||
// let flags = [true, false]
|
||||
// let statuses = articleIDs.map { articleID -> SyncStatus in
|
||||
// let key = keys.randomElement()!
|
||||
// let flag = flags.randomElement()!
|
||||
// let status = SyncStatus(articleID: articleID, key: key, flag: flag)
|
||||
// return status
|
||||
// }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .success(())
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// let syncStatuses: [SyncStatus]
|
||||
// switch action {
|
||||
// case .read:
|
||||
// syncStatuses = statuses.filter { $0.key == .read && $0.flag == true }
|
||||
// case .unread:
|
||||
// syncStatuses = statuses.filter { $0.key == .read && $0.flag == false }
|
||||
// case .saved:
|
||||
// syncStatuses = statuses.filter { $0.key == .starred && $0.flag == true }
|
||||
// case .unsaved:
|
||||
// syncStatuses = statuses.filter { $0.key == .starred && $0.flag == false }
|
||||
// }
|
||||
// let expectedArticleIDs = Set(syncStatuses.map { $0.articleID })
|
||||
// XCTAssertEqual(serviceArticleIDs, expectedArticleIDs)
|
||||
// }
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, 0)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testSendAllFailure() {
|
||||
// let articleIDs = Set((0..<100).map { "feed/0/article/\($0)" })
|
||||
// let keys = [SyncStatus.Key.read, .starred]
|
||||
// let flags = [true, false]
|
||||
// let statuses = articleIDs.map { articleID -> SyncStatus in
|
||||
// let key = keys.randomElement()!
|
||||
// let flag = flags.randomElement()!
|
||||
// let status = SyncStatus(articleID: articleID, key: key, flag: flag)
|
||||
// return status
|
||||
// }
|
||||
//
|
||||
// let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
// container.database.insertStatuses(statuses) { error in
|
||||
// XCTAssertNil(error)
|
||||
// insertExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let service = TestMarkArticlesService()
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
// service.parameterTester = { serviceArticleIDs, action in
|
||||
// let syncStatuses: [SyncStatus]
|
||||
// switch action {
|
||||
// case .read:
|
||||
// syncStatuses = statuses.filter { $0.key == .read && $0.flag == true }
|
||||
// case .unread:
|
||||
// syncStatuses = statuses.filter { $0.key == .read && $0.flag == false }
|
||||
// case .saved:
|
||||
// syncStatuses = statuses.filter { $0.key == .starred && $0.flag == true }
|
||||
// case .unsaved:
|
||||
// syncStatuses = statuses.filter { $0.key == .starred && $0.flag == false }
|
||||
// }
|
||||
// let expectedArticleIDs = Set(syncStatuses.map { $0.articleID })
|
||||
// XCTAssertEqual(serviceArticleIDs, expectedArticleIDs)
|
||||
// }
|
||||
//
|
||||
// let send = FeedlySendArticleStatusesOperation(database: container.database, service: service, log: support.log)
|
||||
//
|
||||
// let didFinishExpectation = expectation(description: "Did Finish")
|
||||
// send.completionBlock = { _ in
|
||||
// didFinishExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(send)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
|
||||
// container.database.selectPendingCount { result in
|
||||
// do {
|
||||
// let statusCount = try result.get()
|
||||
// XCTAssertEqual(statusCount, statuses.count)
|
||||
// selectPendingCountExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping database result: \(error)")
|
||||
// }
|
||||
// }
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// FeedlySyncAllMockResponseProvider.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 1/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class FeedlyMockResponseProvider: TestTransportMockResponseProviding {
|
||||
|
||||
let subdirectory: String
|
||||
|
||||
init(findingMocksIn subdirectory: String) {
|
||||
self.subdirectory = subdirectory
|
||||
}
|
||||
|
||||
func mockResponseFileUrl(for components: URLComponents) -> URL? {
|
||||
let bundle = Bundle(for: FeedlyMockResponseProvider.self)
|
||||
|
||||
// Match request for collections to build a list of folders.
|
||||
if components.path.contains("v3/collections") {
|
||||
return bundle.url(forResource: "collections", withExtension: "json", subdirectory: subdirectory)
|
||||
}
|
||||
|
||||
guard let queryItems = components.queryItems else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match requests for starred articles from global.saved.
|
||||
if components.path.contains("streams/contents") &&
|
||||
queryItems.contains(where: { ($0.value ?? "").contains("global.saved") }) {
|
||||
return bundle.url(forResource: "starred", withExtension: "json", subdirectory: subdirectory)
|
||||
}
|
||||
|
||||
let continuation = queryItems.first(where: { $0.name.contains("continuation") })?.value
|
||||
|
||||
// Match requests for unread article ids.
|
||||
if components.path.contains("streams/ids") && queryItems.contains(where: { $0.name.contains("unreadOnly") }) {
|
||||
|
||||
// if there is a continuation, return the page for it
|
||||
if let continuation = continuation, let data = continuation.data(using: .utf8) {
|
||||
let base64 = data.base64EncodedString() // at least base64 can be used as a path component.
|
||||
return bundle.url(forResource: "unreadIds@\(base64)", withExtension: "json", subdirectory: subdirectory)
|
||||
|
||||
} else {
|
||||
// return first page
|
||||
return bundle.url(forResource: "unreadIds", withExtension: "json", subdirectory: subdirectory)
|
||||
}
|
||||
}
|
||||
|
||||
// Match requests for the contents of global.all.
|
||||
if components.path.contains("streams/contents") &&
|
||||
queryItems.contains(where: { ($0.value ?? "").contains("global.all") }){
|
||||
|
||||
// if there is a continuation, return the page for it
|
||||
if let continuation = continuation, let data = continuation.data(using: .utf8) {
|
||||
let base64 = data.base64EncodedString() // at least base64 can be used as a path component.
|
||||
return bundle.url(forResource: "global.all@\(base64)", withExtension: "json", subdirectory: subdirectory)
|
||||
|
||||
} else {
|
||||
// return first page
|
||||
return bundle.url(forResource: "global.all", withExtension: "json", subdirectory: subdirectory)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// FeedlySyncStreamContentsOperationTests.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 26/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
||||
//
|
||||
// private var account: Account!
|
||||
// private let support = FeedlyTestSupport()
|
||||
//
|
||||
// override func setUp() {
|
||||
// super.setUp()
|
||||
// account = support.makeTestAccount()
|
||||
// }
|
||||
//
|
||||
// override func tearDown() {
|
||||
// if let account = account {
|
||||
// support.destroy(account)
|
||||
// }
|
||||
// super.tearDown()
|
||||
// }
|
||||
//
|
||||
// func testIngestsOnePageSuccess() throws {
|
||||
// let service = TestGetStreamContentsService()
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/1234/category/5678")
|
||||
// let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
|
||||
// let items = service.makeMockFeedlyEntryItem()
|
||||
// service.mockResult = .success(FeedlyStream(id: resource.id, updated: nil, continuation: nil, items: items))
|
||||
//
|
||||
// let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
|
||||
// getStreamContentsExpectation.expectedFulfillmentCount = 1
|
||||
//
|
||||
// service.getStreamContentsExpectation = getStreamContentsExpectation
|
||||
// service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
|
||||
// XCTAssertEqual(serviceResource.id, resource.id)
|
||||
// XCTAssertEqual(serviceNewerThan, newerThan)
|
||||
// XCTAssertNil(continuation)
|
||||
// XCTAssertNil(serviceUnreadOnly)
|
||||
// }
|
||||
//
|
||||
// let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log)
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// syncStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(syncStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
//
|
||||
// let expectedArticleIDs = Set(items.map { $0.id })
|
||||
// let expectedArticles = try account.fetchArticles(.articleIDs(expectedArticleIDs))
|
||||
// XCTAssertEqual(expectedArticles.count, expectedArticleIDs.count, "Did not fetch all the articles.")
|
||||
// }
|
||||
//
|
||||
// func testIngestsOnePageFailure() {
|
||||
// let service = TestGetStreamContentsService()
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/1234/category/5678")
|
||||
// let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
|
||||
//
|
||||
// service.mockResult = .failure(URLError(.timedOut))
|
||||
//
|
||||
// let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
|
||||
// getStreamContentsExpectation.expectedFulfillmentCount = 1
|
||||
//
|
||||
// service.getStreamContentsExpectation = getStreamContentsExpectation
|
||||
// service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
|
||||
// XCTAssertEqual(serviceResource.id, resource.id)
|
||||
// XCTAssertEqual(serviceNewerThan, newerThan)
|
||||
// XCTAssertNil(continuation)
|
||||
// XCTAssertNil(serviceUnreadOnly)
|
||||
// }
|
||||
//
|
||||
// let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log)
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// syncStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(syncStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func testIngestsManyPagesSuccess() throws {
|
||||
// let service = TestGetPagedStreamContentsService()
|
||||
// let resource = FeedlyCategoryResourceID(id: "user/1234/category/5678")
|
||||
// let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
|
||||
//
|
||||
// let continuations = (1...10).map { "\($0)" }
|
||||
// service.addAtLeastOnePage(for: resource, continuations: continuations, numberOfEntriesPerPage: 1000)
|
||||
//
|
||||
// let getStreamContentsExpectation = expectation(description: "Did Get Page of Stream Contents")
|
||||
// getStreamContentsExpectation.expectedFulfillmentCount = 1 + continuations.count
|
||||
//
|
||||
// var remainingContinuations = Set(continuations)
|
||||
// let getStreamPageExpectation = expectation(description: "Did Request Page")
|
||||
// getStreamPageExpectation.expectedFulfillmentCount = 1 + continuations.count
|
||||
//
|
||||
// service.getStreamContentsExpectation = getStreamContentsExpectation
|
||||
// service.parameterTester = { serviceResource, continuation, serviceNewerThan, serviceUnreadOnly in
|
||||
// XCTAssertEqual(serviceResource.id, resource.id)
|
||||
// XCTAssertEqual(serviceNewerThan, newerThan)
|
||||
// XCTAssertNil(serviceUnreadOnly)
|
||||
//
|
||||
// if let continuation = continuation {
|
||||
// XCTAssertTrue(remainingContinuations.contains(continuation))
|
||||
// remainingContinuations.remove(continuation)
|
||||
// }
|
||||
//
|
||||
// getStreamPageExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// let syncStreamContents = FeedlySyncStreamContentsOperation(account: account, resource: resource, service: service, isPagingEnabled: true, newerThan: newerThan, log: support.log)
|
||||
//
|
||||
// let completionExpectation = expectation(description: "Did Finish")
|
||||
// syncStreamContents.completionBlock = { _ in
|
||||
// completionExpectation.fulfill()
|
||||
// }
|
||||
//
|
||||
// MainThreadOperationQueue.shared.add(syncStreamContents)
|
||||
//
|
||||
// waitForExpectations(timeout: 30)
|
||||
//
|
||||
// // Find articles inserted.
|
||||
// let articleIDs = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
|
||||
// let articles = try account.fetchArticles(.articleIDs(articleIDs))
|
||||
// XCTAssertEqual(articleIDs.count, articles.count)
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// FeedlyTestSupport.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 22/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Parser
|
||||
import Secrets
|
||||
@testable import Account
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
|
||||
//class FeedlyTestSupport {
|
||||
// var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "FeedlyTests")
|
||||
// var accessToken = Credentials(type: .oauthAccessToken, username: "Test", secret: "t3st-access-tok3n")
|
||||
// var refreshToken = Credentials(type: .oauthRefreshToken, username: "Test", secret: "t3st-refresh-tok3n")
|
||||
// var transport = TestTransport()
|
||||
//
|
||||
// func makeMockNetworkStack() -> (TestTransport, FeedlyAPICaller) {
|
||||
// let caller = FeedlyAPICaller(transport: transport, api: .sandbox)
|
||||
// caller.credentials = accessToken
|
||||
// return (transport, caller)
|
||||
// }
|
||||
//
|
||||
// func makeTestAccount() -> Account {
|
||||
// let manager = TestAccountManager()
|
||||
// let account = manager.createAccount(type: .feedly, transport: transport)
|
||||
// do {
|
||||
// try account.storeCredentials(refreshToken)
|
||||
// // This must be done last or the account uses the refresh token for request Authorization!
|
||||
// try account.storeCredentials(accessToken)
|
||||
// } catch {
|
||||
// XCTFail("Unable to register mock credentials because \(error)")
|
||||
// }
|
||||
// return account
|
||||
// }
|
||||
//
|
||||
// func makeMockOAuthClient() -> OAuthAuthorizationClient {
|
||||
// return OAuthAuthorizationClient(id: "test", redirectURI: "test://test/auth", state: nil, secret: "password")
|
||||
// }
|
||||
//
|
||||
// func removeCredentials(matching type: CredentialsType, from account: Account) {
|
||||
// do {
|
||||
// try account.removeCredentials(type: type)
|
||||
// } catch {
|
||||
// XCTFail("Unable to remove \(type)")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func makeTestDatabaseContainer() -> TestDatabaseContainer {
|
||||
// return TestDatabaseContainer()
|
||||
// }
|
||||
//
|
||||
// class TestDatabaseContainer {
|
||||
// private let path: String
|
||||
// private(set) var database: SyncDatabase!
|
||||
//
|
||||
// init() {
|
||||
// let dataFolder = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
// path = dataFolder.appendingPathComponent("\(UUID().uuidString)-Sync.sqlite3").path
|
||||
// database = SyncDatabase(databasePath: path)
|
||||
// }
|
||||
//
|
||||
// deinit {
|
||||
// // We should close the database before removing the database.
|
||||
// database = nil
|
||||
// do {
|
||||
// try FileManager.default.removeItem(atPath: path)
|
||||
// print("Removed database at \(path)")
|
||||
// } catch {
|
||||
// print("Unable to remove database owned by \(self) because \(error).")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func destroy(_ testAccount: Account) {
|
||||
// do {
|
||||
// // These should not throw when the keychain items are not found.
|
||||
// try testAccount.removeCredentials(type: .oauthAccessToken)
|
||||
// try testAccount.removeCredentials(type: .oauthRefreshToken)
|
||||
// } catch {
|
||||
// XCTFail("Unable to clean up mock credentials because \(error)")
|
||||
// }
|
||||
//
|
||||
// let manager = TestAccountManager()
|
||||
// manager.deleteAccount(testAccount)
|
||||
// }
|
||||
//
|
||||
// func testJSON(named: String, subdirectory: String? = nil) -> Any {
|
||||
// let url = Bundle.module.url(forResource: named, withExtension: "json", subdirectory: subdirectory)!
|
||||
// let data = try! Data(contentsOf: url)
|
||||
// let json = try! JSONSerialization.jsonObject(with: data)
|
||||
// return json
|
||||
// }
|
||||
//
|
||||
// func checkFoldersAndFeeds(in account: Account, againstCollectionsAndFeedsInJSONNamed name: String, subdirectory: String? = nil) {
|
||||
// let collections = testJSON(named: name, subdirectory: subdirectory) as! [[String:Any]]
|
||||
// let collectionNames = Set(collections.map { $0["label"] as! String })
|
||||
// let collectionIDs = Set(collections.map { $0["id"] as! String })
|
||||
//
|
||||
// let folders = account.folders ?? Set()
|
||||
// let folderNames = Set(folders.compactMap { $0.name })
|
||||
// let folderIDs = Set(folders.compactMap { $0.externalID })
|
||||
//
|
||||
// let missingNames = collectionNames.subtracting(folderNames)
|
||||
// let missingIDs = collectionIDs.subtracting(folderIDs)
|
||||
//
|
||||
// XCTAssertEqual(folders.count, collections.count, "Mismatch between collections and folders.")
|
||||
// XCTAssertTrue(missingNames.isEmpty, "Collections with these names did not have a corresponding folder with the same name.")
|
||||
// XCTAssertTrue(missingIDs.isEmpty, "Collections with these ids did not have a corresponding folder with the same id.")
|
||||
//
|
||||
// for collection in collections {
|
||||
// checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONNamed name: String) {
|
||||
// let collection = testJSON(named: name) as! [String:Any]
|
||||
// checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
|
||||
// }
|
||||
//
|
||||
// func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONPayload collection: [String: Any]) {
|
||||
// let label = collection["label"] as! String
|
||||
// guard let folder = account.existingFolder(with: label) else {
|
||||
// // due to a previous test failure?
|
||||
// XCTFail("Could not find the \"\(label)\" folder.")
|
||||
// return
|
||||
// }
|
||||
// let collectionFeeds = collection["feeds"] as! [[String: Any]]
|
||||
// let folderFeeds = folder.topLevelFeeds
|
||||
//
|
||||
// XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
|
||||
//
|
||||
// let collectionFeedIDs = Set(collectionFeeds.map { $0["id"] as! String })
|
||||
// let folderFeedIDs = Set(folderFeeds.map { $0.feedID })
|
||||
// let missingFeedIDs = collectionFeedIDs.subtracting(folderFeedIDs)
|
||||
//
|
||||
// XCTAssertTrue(missingFeedIDs.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
|
||||
// }
|
||||
//
|
||||
// func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) throws {
|
||||
// let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
||||
// try checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
|
||||
// }
|
||||
//
|
||||
// func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) throws {
|
||||
// try checkArticles(in: account, correspondToStreamItemsIn: stream)
|
||||
// }
|
||||
//
|
||||
// private struct ArticleItem {
|
||||
// var id: String
|
||||
// var feedID: String
|
||||
// var content: String
|
||||
// var JSON: [String: Any]
|
||||
// var unread: Bool
|
||||
//
|
||||
// /// 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? {
|
||||
// return ((JSON["canonical"] as? [[String: Any]]) ?? (JSON["alternate"] as? [[String: Any]]))?.compactMap { link -> String? in
|
||||
// let href = link["href"] as? String
|
||||
// if let type = link["type"] as? String {
|
||||
// if type == "text/html" {
|
||||
// return href
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
// return href
|
||||
// }.first
|
||||
// }
|
||||
//
|
||||
// init(item: [String: Any]) {
|
||||
// self.JSON = item
|
||||
// self.id = item["id"] as! String
|
||||
//
|
||||
// let origin = item["origin"] as! [String: Any]
|
||||
// self.feedID = origin["streamId"] as! String
|
||||
//
|
||||
// let content = item["content"] as? [String: Any]
|
||||
// let summary = item["summary"] as? [String: Any]
|
||||
// self.content = ((content ?? summary)?["content"] as? String) ?? ""
|
||||
//
|
||||
// self.unread = item["unread"] as! Bool
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /// Awkwardly titled to make it clear the JSON given is from a stream response.
|
||||
// func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) throws {
|
||||
//
|
||||
// let items = stream["items"] as! [[String: Any]]
|
||||
// let articleItems = items.map { ArticleItem(item: $0) }
|
||||
// let itemIDs = Set(articleItems.map { $0.id })
|
||||
//
|
||||
// let articles = try testAccount.fetchArticles(.articleIDs(itemIDs))
|
||||
// let articleIDs = Set(articles.map { $0.articleID })
|
||||
//
|
||||
// let missing = itemIDs.subtracting(articleIDs)
|
||||
//
|
||||
// XCTAssertEqual(items.count, articles.count)
|
||||
// XCTAssertTrue(missing.isEmpty, "Items with these ids did not have a corresponding article with the same id.")
|
||||
//
|
||||
// for article in articles {
|
||||
// for item in articleItems where item.id == article.articleID {
|
||||
// XCTAssertEqual(article.uniqueID, item.id)
|
||||
// XCTAssertEqual(article.contentHTML, item.content)
|
||||
// XCTAssertEqual(article.feedID, item.feedId)
|
||||
// XCTAssertEqual(article.externalURL, item.externalUrl)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func checkUnreadStatuses(in account: Account, againstIDsInStreamInJSONNamed name: String, subdirectory: String? = nil, testCase: XCTestCase) {
|
||||
// let streadIDs = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
||||
// checkUnreadStatuses(in: account, correspondToIDsInJSONPayload: streadIDs, testCase: testCase)
|
||||
// }
|
||||
//
|
||||
// func checkUnreadStatuses(in testAccount: Account, correspondToIDsInJSONPayload streadIDs: [String: Any], testCase: XCTestCase) {
|
||||
// let ids = Set(streadIDs["ids"] as! [String])
|
||||
// let fetchIDsExpectation = testCase.expectation(description: "Fetch Article IDs")
|
||||
// testAccount.fetchUnreadArticleIDs { articleIDsResult in
|
||||
// do {
|
||||
// let articleIDs = try articleIDsResult.get()
|
||||
// // Unread statuses can be paged from Feedly.
|
||||
// // Instead of joining test data, the best we can do is
|
||||
// // make sure that these ids are marked as unread (a subset of the total).
|
||||
// XCTAssertTrue(ids.isSubset(of: articleIDs), "Some articles in `ids` are not marked as unread.")
|
||||
// fetchIDsExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping article IDs: \(error)")
|
||||
// }
|
||||
// }
|
||||
// testCase.wait(for: [fetchIDsExpectation], timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func checkStarredStatuses(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil, testCase: XCTestCase) {
|
||||
// let streadIDs = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
||||
// checkStarredStatuses(in: account, correspondToStreamItemsIn: streadIDs, testCase: testCase)
|
||||
// }
|
||||
//
|
||||
// func checkStarredStatuses(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any], testCase: XCTestCase) {
|
||||
// let items = stream["items"] as! [[String: Any]]
|
||||
// let ids = Set(items.map { $0["id"] as! String })
|
||||
// let fetchIDsExpectation = testCase.expectation(description: "Fetch Article Ids")
|
||||
// testAccount.fetchStarredArticleIDs { articleIDsResult in
|
||||
// do {
|
||||
// let articleIDs = try articleIDsResult.get()
|
||||
// // Starred articles can be paged from Feedly.
|
||||
// // Instead of joining test data, the best we can do is
|
||||
// // make sure that these articles are marked as starred (a subset of the total).
|
||||
// XCTAssertTrue(ids.isSubset(of: articleIDs), "Some articles in `ids` are not marked as starred.")
|
||||
// fetchIDsExpectation.fulfill()
|
||||
// } catch {
|
||||
// XCTFail("Error unwrapping article IDs: \(error)")
|
||||
// }
|
||||
// }
|
||||
// testCase.wait(for: [fetchIDsExpectation], timeout: 2)
|
||||
// }
|
||||
//
|
||||
// func check(_ entries: [FeedlyEntry], correspondToStreamItemsIn stream: [String: Any]) {
|
||||
//
|
||||
// let items = stream["items"] as! [[String: Any]]
|
||||
// let itemIDs = Set(items.map { $0["id"] as! String })
|
||||
//
|
||||
// let articleIDs = Set(entries.map { $0.id })
|
||||
//
|
||||
// let missing = itemIDs.subtracting(articleIDs)
|
||||
//
|
||||
// XCTAssertEqual(items.count, entries.count)
|
||||
// XCTAssertTrue(missing.isEmpty, "Failed to create \(FeedlyEntry.self) values from objects in the JSON with these ids.")
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// TestGetCollectionsService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 30/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetCollectionsService: FeedlyGetCollectionsService {
|
||||
// var mockResult: Result<[FeedlyCollection], Error>?
|
||||
// var getCollectionsExpectation: XCTestExpectation?
|
||||
//
|
||||
// func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
|
||||
// guard let result = mockResult else {
|
||||
// XCTFail("Missing mock result. Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// DispatchQueue.main.async {
|
||||
// completion(result)
|
||||
// self.getCollectionsExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// TestGetEntriesService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 11/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetEntriesService: FeedlyGetEntriesService {
|
||||
// var mockResult: Result<[FeedlyEntry], Error>?
|
||||
// var getEntriesExpectation: XCTestExpectation?
|
||||
//
|
||||
// func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> ()) {
|
||||
// guard let result = mockResult else {
|
||||
// XCTFail("Missing mock result. Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// DispatchQueue.main.async {
|
||||
// completion(result)
|
||||
// self.getEntriesExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// TestGetPagedStreamContentsService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 28/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetPagedStreamContentsService: FeedlyGetStreamContentsService {
|
||||
//
|
||||
// var parameterTester: ((FeedlyResourceID, String?, Date?, Bool?) -> ())?
|
||||
// var getStreamContentsExpectation: XCTestExpectation?
|
||||
// var pages = [String: FeedlyStream]()
|
||||
//
|
||||
// func addAtLeastOnePage(for resource: FeedlyResourceID, continuations: [String], numberOfEntriesPerPage count: Int) {
|
||||
// pages = [String: FeedlyStream](minimumCapacity: continuations.count + 1)
|
||||
//
|
||||
// // A continuation is an identifier for the next page.
|
||||
// // The first page has a nil identifier.
|
||||
// // The last page has no next page, so the next continuation value for that page is nil.
|
||||
// // Therefore, each page needs to know the identifier of the next page.
|
||||
// for index in -1..<continuations.count {
|
||||
// let nextIndex = index + 1
|
||||
// let continuation: String? = nextIndex < continuations.count ? continuations[nextIndex] : nil
|
||||
// let page = makeStreamContents(for: resource, continuation: continuation, between: 0..<count)
|
||||
// let key = TestGetPagedStreamContentsService.getPagingKey(for: resource, continuation: index < 0 ? nil : continuations[index])
|
||||
// pages[key] = page
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func makeStreamContents(for resource: FeedlyResourceID, continuation: String?, between range: Range<Int>) -> FeedlyStream {
|
||||
// let entries = range.map { index -> FeedlyEntry in
|
||||
// let content = FeedlyEntry.Content(content: "Content \(index)",
|
||||
// direction: .leftToRight)
|
||||
//
|
||||
// let origin = FeedlyOrigin(title: "Origin \(index)",
|
||||
// streamId: resource.id,
|
||||
// htmlUrl: "http://localhost/feedly/origin/\(index)")
|
||||
//
|
||||
// return FeedlyEntry(id: "/articles/\(index)",
|
||||
// title: "Article \(index)",
|
||||
// content: content,
|
||||
// summary: content,
|
||||
// author: nil,
|
||||
// crawled: Date(),
|
||||
// recrawled: nil,
|
||||
// origin: origin,
|
||||
// canonical: nil,
|
||||
// alternate: nil,
|
||||
// unread: true,
|
||||
// tags: nil,
|
||||
// categories: nil,
|
||||
// enclosure: nil)
|
||||
// }
|
||||
//
|
||||
// let stream = FeedlyStream(id: resource.id, updated: nil, continuation: continuation, items: entries)
|
||||
//
|
||||
// return stream
|
||||
// }
|
||||
//
|
||||
// static func getPagingKey(for stream: FeedlyResourceID, continuation: String?) -> String {
|
||||
// return "\(stream.id)@\(continuation ?? "")"
|
||||
// }
|
||||
//
|
||||
// func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
|
||||
// let key = TestGetPagedStreamContentsService.getPagingKey(for: resource, continuation: continuation)
|
||||
// guard let page = pages[key] else {
|
||||
// XCTFail("Missing page for \(resource.id) and continuation \(String(describing: continuation)). Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// parameterTester?(resource, continuation, newerThan, unreadOnly)
|
||||
// DispatchQueue.main.async {
|
||||
// completion(.success(page))
|
||||
// self.getStreamContentsExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// TestGetPagedStreadIDsService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 29/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetPagedStreadIDsService: FeedlyGetStreamIDsService {
|
||||
//
|
||||
// var parameterTester: ((FeedlyResourceID, String?, Date?, Bool?) -> ())?
|
||||
// var getStreadIDsExpectation: XCTestExpectation?
|
||||
// var pages = [String: FeedlyStreamIDs]()
|
||||
//
|
||||
// func addAtLeastOnePage(for resource: FeedlyResourceID, continuations: [String], numberOfEntriesPerPage count: Int) {
|
||||
// pages = [String: FeedlyStreamIDs](minimumCapacity: continuations.count + 1)
|
||||
//
|
||||
// // A continuation is an identifier for the next page.
|
||||
// // The first page has a nil identifier.
|
||||
// // The last page has no next page, so the next continuation value for that page is nil.
|
||||
// // Therefore, each page needs to know the identifier of the next page.
|
||||
// for index in -1..<continuations.count {
|
||||
// let nextIndex = index + 1
|
||||
// let continuation: String? = nextIndex < continuations.count ? continuations[nextIndex] : nil
|
||||
// let page = makeStreadIDs(for: resource, continuation: continuation, between: 0..<count)
|
||||
// let key = TestGetPagedStreadIDsService.getPagingKey(for: resource, continuation: index < 0 ? nil : continuations[index])
|
||||
// pages[key] = page
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func makeStreadIDs(for resource: FeedlyResourceID, continuation: String?, between range: Range<Int>) -> FeedlyStreamIDs {
|
||||
// let entryIDs = range.map { _ in UUID().uuidString }
|
||||
// let stream = FeedlyStreamIDs(continuation: continuation, ids: entryIDs)
|
||||
// return stream
|
||||
// }
|
||||
//
|
||||
// static func getPagingKey(for stream: FeedlyResourceID, continuation: String?) -> String {
|
||||
// return "\(stream.id)@\(continuation ?? "")"
|
||||
// }
|
||||
//
|
||||
// func getStreamIDs(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIDs, Error>) -> ()) {
|
||||
// let key = TestGetPagedStreadIDsService.getPagingKey(for: resource, continuation: continuation)
|
||||
// guard let page = pages[key] else {
|
||||
// XCTFail("Missing page for \(resource.id) and continuation \(String(describing: continuation)). Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// parameterTester?(resource, continuation, newerThan, unreadOnly)
|
||||
// DispatchQueue.main.async {
|
||||
// completion(.success(page))
|
||||
// self.getStreadIDsExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// TestGetStreamContentsService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 28/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetStreamContentsService: FeedlyGetStreamContentsService {
|
||||
//
|
||||
// var mockResult: Result<FeedlyStream, Error>?
|
||||
// var parameterTester: ((FeedlyResourceID, String?, Date?, Bool?) -> ())?
|
||||
// var getStreamContentsExpectation: XCTestExpectation?
|
||||
//
|
||||
// func getStreamContents(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
|
||||
// guard let result = mockResult else {
|
||||
// XCTFail("Missing mock result. Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// parameterTester?(resource, continuation, newerThan, unreadOnly)
|
||||
// DispatchQueue.main.async {
|
||||
// completion(result)
|
||||
// self.getStreamContentsExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func makeMockFeedlyEntryItem() -> [FeedlyEntry] {
|
||||
// let origin = FeedlyOrigin(title: "XCTest@localhost", streamId: "user/12345/category/67890", htmlUrl: "http://localhost/nnw/xctest")
|
||||
// let content = FeedlyEntry.Content(content: "In the beginning...", direction: .leftToRight)
|
||||
// let items = [FeedlyEntry(id: "feeds/0/article/0",
|
||||
// title: "RSS Reader Ingests Man",
|
||||
// content: content,
|
||||
// summary: content,
|
||||
// author: nil,
|
||||
// crawled: Date(),
|
||||
// recrawled: nil,
|
||||
// origin: origin,
|
||||
// canonical: nil,
|
||||
// alternate: nil,
|
||||
// unread: true,
|
||||
// tags: nil,
|
||||
// categories: nil,
|
||||
// enclosure: nil)]
|
||||
// return items
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// TestGetStreadIDsService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 29/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//final class TestGetStreadIDsService: FeedlyGetStreamIDsService {
|
||||
//
|
||||
// var mockResult: Result<FeedlyStreamIDs, Error>?
|
||||
// var parameterTester: ((FeedlyResourceID, String?, Date?, Bool?) -> ())?
|
||||
// var getStreadIDsExpectation: XCTestExpectation?
|
||||
//
|
||||
// func getStreamIDs(for resource: FeedlyResourceID, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIDs, Error>) -> ()) {
|
||||
// guard let result = mockResult else {
|
||||
// XCTFail("Missing mock result. Test may time out because the completion will not be called.")
|
||||
// return
|
||||
// }
|
||||
// parameterTester?(resource, continuation, newerThan, unreadOnly)
|
||||
// DispatchQueue.main.async {
|
||||
// completion(result)
|
||||
// self.getStreadIDsExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// TestMarkArticlesService.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Kiel Gillard on 30/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
|
||||
//class TestMarkArticlesService: FeedlyMarkArticlesService {
|
||||
//
|
||||
// var didMarkExpectation: XCTestExpectation?
|
||||
// var parameterTester: ((Set<String>, FeedlyMarkAction) -> ())?
|
||||
// var mockResult: Result<Void, Error> = .success(())
|
||||
//
|
||||
// func mark(_ articleIDs: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
//
|
||||
// DispatchQueue.main.async {
|
||||
// self.parameterTester?(articleIDs, action)
|
||||
// completion(self.mockResult)
|
||||
// self.didMarkExpectation?.fulfill()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1005
Modules/Account/Tests/AccountTests/JSON/feedly_unreads_1000.json
Normal file
1005
Modules/Account/Tests/AccountTests/JSON/feedly_unreads_1000.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ids": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1117
Modules/Account/Tests/AccountTests/JSON/taggings_add.json
Normal file
1117
Modules/Account/Tests/AccountTests/JSON/taggings_add.json
Normal file
File diff suppressed because it is too large
Load Diff
1097
Modules/Account/Tests/AccountTests/JSON/taggings_delete.json
Normal file
1097
Modules/Account/Tests/AccountTests/JSON/taggings_delete.json
Normal file
File diff suppressed because it is too large
Load Diff
1112
Modules/Account/Tests/AccountTests/JSON/taggings_initial.json
Normal file
1112
Modules/Account/Tests/AccountTests/JSON/taggings_initial.json
Normal file
File diff suppressed because it is too large
Load Diff
42
Modules/Account/Tests/AccountTests/JSON/tags_add.json
Normal file
42
Modules/Account/Tests/AccountTests/JSON/tags_add.json
Normal file
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": 9754,
|
||||
"name": "Amusement"
|
||||
},
|
||||
{
|
||||
"id": 247,
|
||||
"name": "Business"
|
||||
},
|
||||
{
|
||||
"id": 1049,
|
||||
"name": "Developers"
|
||||
},
|
||||
{
|
||||
"id": 102244,
|
||||
"name": "Development Orgs"
|
||||
},
|
||||
{
|
||||
"id": 40541,
|
||||
"name": "Open Web"
|
||||
},
|
||||
{
|
||||
"id": 1337,
|
||||
"name": "Outdoors"
|
||||
},
|
||||
{
|
||||
"id": 56975,
|
||||
"name": "Overlanding"
|
||||
},
|
||||
{
|
||||
"id": 3782,
|
||||
"name": "Pundits"
|
||||
},
|
||||
{
|
||||
"id": 7827,
|
||||
"name": "Tech Media"
|
||||
},
|
||||
{
|
||||
"id": 97769,
|
||||
"name": "Vanlife"
|
||||
}
|
||||
]
|
||||
34
Modules/Account/Tests/AccountTests/JSON/tags_delete.json
Normal file
34
Modules/Account/Tests/AccountTests/JSON/tags_delete.json
Normal file
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"id": 9754,
|
||||
"name": "Amusement"
|
||||
},
|
||||
{
|
||||
"id": 247,
|
||||
"name": "Business"
|
||||
},
|
||||
{
|
||||
"id": 1049,
|
||||
"name": "Developers"
|
||||
},
|
||||
{
|
||||
"id": 102244,
|
||||
"name": "Development Orgs"
|
||||
},
|
||||
{
|
||||
"id": 40541,
|
||||
"name": "Open Web"
|
||||
},
|
||||
{
|
||||
"id": 1337,
|
||||
"name": "Outdoors"
|
||||
},
|
||||
{
|
||||
"id": 56975,
|
||||
"name": "Overlanding"
|
||||
},
|
||||
{
|
||||
"id": 3782,
|
||||
"name": "Pundits"
|
||||
}
|
||||
]
|
||||
38
Modules/Account/Tests/AccountTests/JSON/tags_initial.json
Normal file
38
Modules/Account/Tests/AccountTests/JSON/tags_initial.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"id": 9754,
|
||||
"name": "Amusement"
|
||||
},
|
||||
{
|
||||
"id": 247,
|
||||
"name": "Business"
|
||||
},
|
||||
{
|
||||
"id": 1049,
|
||||
"name": "Developers"
|
||||
},
|
||||
{
|
||||
"id": 102244,
|
||||
"name": "Development Orgs"
|
||||
},
|
||||
{
|
||||
"id": 40541,
|
||||
"name": "Open Web"
|
||||
},
|
||||
{
|
||||
"id": 1337,
|
||||
"name": "Outdoors"
|
||||
},
|
||||
{
|
||||
"id": 56975,
|
||||
"name": "Overlanding"
|
||||
},
|
||||
{
|
||||
"id": 3782,
|
||||
"name": "Pundits"
|
||||
},
|
||||
{
|
||||
"id": 7827,
|
||||
"name": "Tech Media"
|
||||
}
|
||||
]
|
||||
55
Modules/Account/Tests/AccountTests/TestAccountManager.swift
Normal file
55
Modules/Account/Tests/AccountTests/TestAccountManager.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// TestAccountManager.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/4/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
@testable import Account
|
||||
|
||||
//final class TestAccountManager {
|
||||
//
|
||||
// static let shared = TestAccountManager()
|
||||
//
|
||||
// var accountsFolder: URL {
|
||||
// return try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func createAccount(type: AccountType, username: String? = nil, password: String? = nil, transport: Transport) -> Account {
|
||||
//
|
||||
// let accountID = UUID().uuidString
|
||||
// let accountFolder = accountsFolder.appendingPathComponent("\(type.rawValue)_\(accountID)")
|
||||
//
|
||||
// do {
|
||||
// try FileManager.default.createDirectory(at: accountFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
// } catch {
|
||||
// assertionFailure("Could not create folder for \(accountID) account.")
|
||||
// abort()
|
||||
// }
|
||||
//
|
||||
// let account = Account(dataFolder: accountFolder.absoluteString, type: type, accountID: accountID, transport: transport)
|
||||
//
|
||||
// return account
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func deleteAccount(_ account: Account) {
|
||||
//
|
||||
// do {
|
||||
// try FileManager.default.removeItem(atPath: account.dataFolder)
|
||||
// }
|
||||
// catch let error as CocoaError where error.code == .fileNoSuchFile {
|
||||
//
|
||||
// }
|
||||
// catch {
|
||||
// assertionFailure("Could not delete folder at: \(account.dataFolder) because \(error)")
|
||||
// abort()
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
87
Modules/Account/Tests/AccountTests/TestTransport.swift
Normal file
87
Modules/Account/Tests/AccountTests/TestTransport.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// TestTransport.swift
|
||||
// AccountTests
|
||||
//
|
||||
// Created by Maurice Parker on 5/4/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import XCTest
|
||||
|
||||
protocol TestTransportMockResponseProviding: AnyObject {
|
||||
func mockResponseFileUrl(for components: URLComponents) -> URL?
|
||||
}
|
||||
|
||||
//final class TestTransport: Transport {
|
||||
//
|
||||
// enum TestTransportError: String, Error {
|
||||
// case invalidState = "The test wasn't set up correctly."
|
||||
// }
|
||||
//
|
||||
// var testFiles = [String: String]()
|
||||
// var testStatusCodes = [String: Int]()
|
||||
//
|
||||
// weak var mockResponseFileUrlProvider: TestTransportMockResponseProviding?
|
||||
//
|
||||
// private func httpResponse(for request: URLRequest, statusCode: Int = 200) -> HTTPURLResponse {
|
||||
// guard let url = request.url else {
|
||||
// fatalError("Attempting to mock a http response for a request without a URL \(request).")
|
||||
// }
|
||||
// return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||
// }
|
||||
//
|
||||
// func cancelAll() { }
|
||||
//
|
||||
// func send(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
|
||||
//
|
||||
// guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
// completion(.failure(TestTransportError.invalidState))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let urlString = url.absoluteString
|
||||
// let response = httpResponse(for: request, statusCode: testStatusCodes[urlString] ?? 200)
|
||||
// let testFileURL: URL
|
||||
//
|
||||
// if let provider = mockResponseFileUrlProvider {
|
||||
// guard let providerUrl = provider.mockResponseFileUrl(for: components) else {
|
||||
// XCTFail("Test behaviour undefined. Mock provider failed to provide non-nil URL for \(components).")
|
||||
// return
|
||||
// }
|
||||
// testFileURL = providerUrl
|
||||
//
|
||||
// } else if let testKeyAndFileName = testFiles.first(where: { urlString.contains($0.key) }) {
|
||||
// testFileURL = Bundle.module.resourceURL!.appendingPathComponent(testKeyAndFileName.value)
|
||||
//
|
||||
// } else {
|
||||
// // XCTFail("Missing mock response for: \(urlString)")
|
||||
// print("***\nWARNING: \(self) missing mock response for:\n\(urlString)\n***")
|
||||
// DispatchQueue.global(qos: .background).async {
|
||||
// completion(.success((response, nil)))
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// do {
|
||||
// let data = try Data(contentsOf: testFileURL)
|
||||
// DispatchQueue.global(qos: .background).async {
|
||||
// completion(.success((response, data)))
|
||||
// }
|
||||
// } catch {
|
||||
// XCTFail("Unable to read file at \(testFileURL) because \(error).")
|
||||
// DispatchQueue.global(qos: .background).async {
|
||||
// completion(.failure(error))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func send(request: URLRequest, method: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
// fatalError("Unimplemented.")
|
||||
// }
|
||||
//
|
||||
// func send(request: URLRequest, method: String, payload: Data, completion: @escaping (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
|
||||
// fatalError("Unimplemented.")
|
||||
// }
|
||||
//}
|
||||
7
Modules/Account/Tests/LinuxMain.swift
Normal file
7
Modules/Account/Tests/LinuxMain.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import XCTest
|
||||
|
||||
import AccountTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += AccountTests.allTests()
|
||||
XCTMain(tests)
|
||||
8
Modules/AppKitExtras/.gitignore
vendored
Normal file
8
Modules/AppKitExtras/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1530"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AppKitExtras"
|
||||
BuildableName = "AppKitExtras"
|
||||
BlueprintName = "AppKitExtras"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AppKitExtrasTests"
|
||||
BuildableName = "AppKitExtrasTests"
|
||||
BlueprintName = "AppKitExtrasTests"
|
||||
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 = "AppKitExtras"
|
||||
BuildableName = "AppKitExtras"
|
||||
BlueprintName = "AppKitExtras"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1530"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AppKitExtrasTests"
|
||||
BuildableName = "AppKitExtrasTests"
|
||||
BlueprintName = "AppKitExtrasTests"
|
||||
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">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
31
Modules/AppKitExtras/Package.swift
Normal file
31
Modules/AppKitExtras/Package.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version: 5.10
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AppKitExtras",
|
||||
platforms: [.macOS(.v14), .iOS(.v17)],
|
||||
products: [
|
||||
.library(
|
||||
name: "AppKitExtras",
|
||||
type: .dynamic,
|
||||
targets: ["AppKitExtras"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../FoundationExtras")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AppKitExtras",
|
||||
dependencies: [
|
||||
"FoundationExtras",
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AppKitExtrasTests",
|
||||
dependencies: ["AppKitExtras"]),
|
||||
]
|
||||
)
|
||||
37
Modules/AppKitExtras/Sources/AppKitExtras/FourCharCode.swift
Normal file
37
Modules/AppKitExtras/Sources/AppKitExtras/FourCharCode.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// FourCharCode.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Olof Hellman on 1/7/18.
|
||||
// Copyright © 2018 Olof Hellman. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension String {
|
||||
|
||||
/// Converts a string to a `FourCharCode`.
|
||||
///
|
||||
/// `FourCharCode` values like `OSType`, `DescType` or `AEKeyword` are really just
|
||||
/// 4-byte values commonly represented as values like `'odoc'` where each byte is
|
||||
/// represented as its ASCII character. This property turns a Swift string into
|
||||
/// its `FourCharCode` equivalent, as Swift doesn't recognize `FourCharCode` types
|
||||
/// natively just yet. With this extension, one can use `"odoc".fourCharCode`
|
||||
/// where one would really want to use `'odoc'`.
|
||||
var fourCharCode: FourCharCode {
|
||||
precondition(count == 4)
|
||||
var sum: UInt32 = 0
|
||||
for scalar in self.unicodeScalars {
|
||||
sum = (sum * 256) + scalar.value
|
||||
}
|
||||
return sum
|
||||
}
|
||||
}
|
||||
|
||||
public extension Int {
|
||||
|
||||
var fourCharCode: FourCharCode {
|
||||
return UInt32(self)
|
||||
}
|
||||
}
|
||||
|
||||
153
Modules/AppKitExtras/Sources/AppKitExtras/Keyboard.swift
Normal file
153
Modules/AppKitExtras/Sources/AppKitExtras/Keyboard.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// Keyboard.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 12/19/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
|
||||
private extension String {
|
||||
|
||||
var keyboardIntegerValue: Int? {
|
||||
if isEmpty {
|
||||
return nil
|
||||
}
|
||||
let utf16String = utf16
|
||||
let startIndex = utf16String.startIndex
|
||||
if startIndex == utf16String.endIndex {
|
||||
return nil
|
||||
}
|
||||
return Int(utf16String[startIndex])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor public struct KeyboardShortcut: Hashable {
|
||||
|
||||
public let key: KeyboardKey
|
||||
public let actionString: String
|
||||
|
||||
public init?(dictionary: [String: Any]) {
|
||||
|
||||
guard let key = KeyboardKey(dictionary: dictionary) else {
|
||||
return nil
|
||||
}
|
||||
guard let actionString = dictionary["action"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.key = key
|
||||
self.actionString = actionString
|
||||
}
|
||||
|
||||
public func perform(with view: NSView) {
|
||||
|
||||
let action = NSSelectorFromString(actionString)
|
||||
NSApplication.shared.sendAction(action, to: nil, from: view)
|
||||
}
|
||||
|
||||
public static func findMatchingShortcut(in shortcuts: Set<KeyboardShortcut>, key: KeyboardKey) -> KeyboardShortcut? {
|
||||
|
||||
for shortcut in shortcuts {
|
||||
if shortcut.key == key {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
nonisolated public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(key)
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardKey: Hashable, Sendable {
|
||||
|
||||
public let shiftKeyDown: Bool
|
||||
public let optionKeyDown: Bool
|
||||
public let commandKeyDown: Bool
|
||||
public let controlKeyDown: Bool
|
||||
public let integerValue: Int // unmodified character as Int
|
||||
|
||||
init(integerValue: Int, shiftKeyDown: Bool, optionKeyDown: Bool, commandKeyDown: Bool, controlKeyDown: Bool) {
|
||||
|
||||
self.integerValue = integerValue
|
||||
|
||||
self.shiftKeyDown = shiftKeyDown
|
||||
self.optionKeyDown = optionKeyDown
|
||||
self.commandKeyDown = commandKeyDown
|
||||
self.controlKeyDown = controlKeyDown
|
||||
}
|
||||
|
||||
static let deleteKeyCode = 127
|
||||
|
||||
public init(with event: NSEvent) {
|
||||
|
||||
let flags = event.modifierFlags
|
||||
let shiftKeyDown = flags.contains(.shift)
|
||||
let optionKeyDown = flags.contains(.option)
|
||||
let commandKeyDown = flags.contains(.command)
|
||||
let controlKeyDown = flags.contains(.control)
|
||||
|
||||
let integerValue = event.charactersIgnoringModifiers?.keyboardIntegerValue ?? 0
|
||||
|
||||
self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown)
|
||||
}
|
||||
|
||||
public init?(dictionary: [String: Any]) {
|
||||
|
||||
guard let s = dictionary["key"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var integerValue = 0
|
||||
|
||||
switch(s) {
|
||||
case "[space]":
|
||||
integerValue = " ".keyboardIntegerValue!
|
||||
case "[uparrow]":
|
||||
integerValue = NSUpArrowFunctionKey
|
||||
case "[downarrow]":
|
||||
integerValue = NSDownArrowFunctionKey
|
||||
case "[leftarrow]":
|
||||
integerValue = NSLeftArrowFunctionKey
|
||||
case "[rightarrow]":
|
||||
integerValue = NSRightArrowFunctionKey
|
||||
case "[return]":
|
||||
integerValue = NSCarriageReturnCharacter
|
||||
case "[enter]":
|
||||
integerValue = NSEnterCharacter
|
||||
case "[delete]":
|
||||
integerValue = KeyboardKey.deleteKeyCode
|
||||
case "[deletefunction]":
|
||||
integerValue = NSDeleteFunctionKey
|
||||
case "[tab]":
|
||||
integerValue = NSTabCharacter
|
||||
default:
|
||||
guard let unwrappedIntegerValue = s.keyboardIntegerValue else {
|
||||
return nil
|
||||
}
|
||||
integerValue = unwrappedIntegerValue
|
||||
}
|
||||
|
||||
let shiftKeyDown = dictionary["shiftModifier"] as? Bool ?? false
|
||||
let optionKeyDown = dictionary["optionModifier"] as? Bool ?? false
|
||||
let commandKeyDown = dictionary["commandModifier"] as? Bool ?? false
|
||||
let controlKeyDown = dictionary["controlModifier"] as? Bool ?? false
|
||||
|
||||
self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown)
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(integerValue)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// KeyboardDelegateProtocol.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/11/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
//let keypadEnter: unichar = 3
|
||||
|
||||
@objc public protocol KeyboardDelegate: AnyObject {
|
||||
|
||||
// Return true if handled.
|
||||
@MainActor func keydown(_: NSEvent, in view: NSView) -> Bool
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// NSAppearance+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Daniel Jalkut on 8/28/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSAppearance {
|
||||
|
||||
public var isDarkMode: Bool {
|
||||
bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// NSAppleEventDescriptor+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-02.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSAppleEventDescriptor {
|
||||
|
||||
/// An NSAppleEventDescriptor describing a running application.
|
||||
///
|
||||
/// - Parameter runningApplication: A running application to associate with the descriptor.
|
||||
///
|
||||
/// - Returns: An instance of `NSAppleEventDescriptor` that refers to the running application,
|
||||
/// or `nil` if the running application has no process ID.
|
||||
convenience init?(runningApplication: NSRunningApplication) {
|
||||
|
||||
let pid = runningApplication.processIdentifier
|
||||
if pid == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.init(processIdentifier: pid)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// NSImage+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 12/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSImage {
|
||||
|
||||
func tinted(with color: NSColor) -> NSImage {
|
||||
|
||||
let image = self.copy() as! NSImage
|
||||
|
||||
image.lockFocus()
|
||||
|
||||
color.set()
|
||||
let rect = NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
|
||||
rect.fill(using: .sourceAtop)
|
||||
|
||||
image.unlockFocus()
|
||||
|
||||
image.isTemplate = false
|
||||
return image
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// NSMenu+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/9/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSMenu {
|
||||
|
||||
func takeItems(from menu: NSMenu) {
|
||||
|
||||
// The passed-in menu gets all its items removed.
|
||||
|
||||
let items = menu.items
|
||||
menu.removeAllItems()
|
||||
for menuItem in items {
|
||||
addItem(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a separator if there are multiple menu items and the last one is not a separator.
|
||||
func addSeparatorIfNeeded() {
|
||||
if items.count > 0 && !items.last!.isSeparatorItem {
|
||||
addItem(NSMenuItem.separator())
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
168
Modules/AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift
Executable file
168
Modules/AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift
Executable file
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// NSOutlineView+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 9/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSOutlineView {
|
||||
|
||||
var selectedItems: [AnyObject] {
|
||||
if selectionIsEmpty {
|
||||
return [AnyObject]()
|
||||
}
|
||||
|
||||
return selectedRowIndexes.compactMap { (oneIndex) -> AnyObject? in
|
||||
return item(atRow: oneIndex) as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
var firstSelectedRow: Int? {
|
||||
|
||||
if selectionIsEmpty {
|
||||
return nil
|
||||
}
|
||||
return selectedRowIndexes.first
|
||||
}
|
||||
|
||||
var lastSelectedRow: Int? {
|
||||
|
||||
if selectionIsEmpty {
|
||||
return nil
|
||||
}
|
||||
return selectedRowIndexes.last
|
||||
}
|
||||
|
||||
@IBAction func selectPreviousRow(_ sender: Any?) {
|
||||
|
||||
guard var row = firstSelectedRow else {
|
||||
return
|
||||
}
|
||||
|
||||
if row < 1 {
|
||||
return
|
||||
}
|
||||
while true {
|
||||
row -= 1
|
||||
if row < 0 {
|
||||
return
|
||||
}
|
||||
if canSelect(row) {
|
||||
selectRowAndScrollToVisible(row)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func selectNextRow(_ sender: Any?) {
|
||||
|
||||
// If no selectedRow, end up at first selectable row.
|
||||
var row = lastSelectedRow ?? -1
|
||||
|
||||
while true {
|
||||
row += 1
|
||||
if let _ = item(atRow: row) {
|
||||
if canSelect(row) {
|
||||
selectRowAndScrollToVisible(row)
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
return // if there are no more items, we’re out of rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func collapseSelectedRows(_ sender: Any?) {
|
||||
|
||||
for item in selectedItems {
|
||||
if isExpandable(item) && isItemExpanded(item) {
|
||||
animator().collapseItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func expandSelectedRows(_ sender: Any?) {
|
||||
|
||||
for item in selectedItems {
|
||||
if isExpandable(item) && !isItemExpanded(item) {
|
||||
animator().expandItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func expandAll(_ sender: Any?) {
|
||||
|
||||
expandAllChildren(of: nil)
|
||||
}
|
||||
|
||||
@IBAction func collapseAllExceptForGroupItems(_ sender: Any?) {
|
||||
|
||||
collapseAllChildren(of: nil, exceptForGroupItems: true)
|
||||
}
|
||||
|
||||
func expandAllChildren(of item: Any?) {
|
||||
|
||||
guard let childItems = children(of: item) else {
|
||||
return
|
||||
}
|
||||
|
||||
for child in childItems {
|
||||
if !isItemExpanded(child) && isExpandable(child) {
|
||||
animator().expandItem(child, expandChildren: true)
|
||||
}
|
||||
expandAllChildren(of: child)
|
||||
}
|
||||
}
|
||||
|
||||
func collapseAllChildren(of item: Any?, exceptForGroupItems: Bool) {
|
||||
|
||||
guard let childItems = children(of: item) else {
|
||||
return
|
||||
}
|
||||
|
||||
for child in childItems {
|
||||
collapseAllChildren(of: child, exceptForGroupItems: exceptForGroupItems)
|
||||
if exceptForGroupItems && isGroupItem(child) {
|
||||
continue
|
||||
}
|
||||
if isItemExpanded(child) {
|
||||
animator().collapseItem(child, collapseChildren: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func children(of item: Any?) -> [Any]? {
|
||||
|
||||
var children = [Any]()
|
||||
for indexOfItem in 0..<numberOfChildren(ofItem: item) {
|
||||
if let child = child(indexOfItem, ofItem: item) {
|
||||
children.append(child)
|
||||
}
|
||||
}
|
||||
return children.isEmpty ? nil : children
|
||||
}
|
||||
|
||||
func isGroupItem(_ item: Any) -> Bool {
|
||||
|
||||
return delegate?.outlineView?(self, isGroupItem: item) ?? false
|
||||
}
|
||||
|
||||
func canSelect(_ row: Int) -> Bool {
|
||||
|
||||
guard let item = item(atRow: row) else {
|
||||
return false
|
||||
}
|
||||
return canSelectItem(item)
|
||||
}
|
||||
|
||||
func canSelectItem(_ item: Any) -> Bool {
|
||||
|
||||
let isSelectable = delegate?.outlineView?(self, shouldSelectItem: item) ?? true
|
||||
return isSelectable
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// NSPasteboard+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSPasteboard {
|
||||
|
||||
@MainActor func copyObjects(_ objects: [Any]) {
|
||||
|
||||
guard let writers = writersFor(objects) else {
|
||||
return
|
||||
}
|
||||
|
||||
clearContents()
|
||||
writeObjects(writers)
|
||||
}
|
||||
|
||||
func canCopyAtLeastOneObject(_ objects: [Any]) -> Bool {
|
||||
|
||||
for object in objects {
|
||||
if object is PasteboardWriterOwner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension NSPasteboard {
|
||||
|
||||
static func urlString(from pasteboard: NSPasteboard) -> String? {
|
||||
return pasteboard.urlString
|
||||
}
|
||||
|
||||
private var urlString: String? {
|
||||
guard let type = self.availableType(from: [.string]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let str = self.string(forType: type), !str.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return str.mayBeURL ? str : nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NSPasteboard {
|
||||
|
||||
@MainActor func writersFor(_ objects: [Any]) -> [NSPasteboardWriting]? {
|
||||
|
||||
let writers = objects.compactMap { ($0 as? PasteboardWriterOwner)?.pasteboardWriter }
|
||||
return writers.isEmpty ? nil : writers
|
||||
}
|
||||
}
|
||||
#endif
|
||||
31
Modules/AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift
Executable file
31
Modules/AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift
Executable file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// NSResponder-Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSResponder {
|
||||
|
||||
func hasAncestor(_ ancestor: NSResponder) -> Bool {
|
||||
|
||||
var nomad: NSResponder = self
|
||||
while(true) {
|
||||
if nomad === ancestor {
|
||||
return true
|
||||
}
|
||||
if let _ = nomad.nextResponder {
|
||||
nomad = nomad.nextResponder!
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
108
Modules/AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift
Executable file
108
Modules/AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift
Executable file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// NSTableView+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 9/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSTableView {
|
||||
|
||||
var selectionIsEmpty: Bool {
|
||||
return selectedRowIndexes.startIndex == selectedRowIndexes.endIndex
|
||||
}
|
||||
|
||||
func indexesOfAvailableRowsPassingTest(_ test: (Int) -> Bool) -> IndexSet? {
|
||||
|
||||
// Checks visible and in-flight rows.
|
||||
|
||||
var indexes = IndexSet()
|
||||
enumerateAvailableRowViews { (_, row) in
|
||||
if test(row) {
|
||||
indexes.insert(row)
|
||||
}
|
||||
}
|
||||
|
||||
return indexes.isEmpty ? nil : indexes
|
||||
}
|
||||
|
||||
func indexesOfAvailableRows() -> IndexSet? {
|
||||
|
||||
var indexes = IndexSet()
|
||||
enumerateAvailableRowViews { indexes.insert($1) }
|
||||
return indexes.isEmpty ? nil : indexes
|
||||
}
|
||||
|
||||
func scrollTo(row: Int, extraHeight: Int = 150) {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView else {
|
||||
return
|
||||
}
|
||||
let documentVisibleRect = scrollView.documentVisibleRect
|
||||
|
||||
let r = rect(ofRow: row)
|
||||
if NSContainsRect(documentVisibleRect, r) {
|
||||
return
|
||||
}
|
||||
|
||||
let rMidY = NSMidY(r)
|
||||
var scrollPoint = NSZeroPoint;
|
||||
scrollPoint.y = floor(rMidY - (documentVisibleRect.size.height / 2.0)) + CGFloat(extraHeight)
|
||||
scrollPoint.y = max(scrollPoint.y, 0)
|
||||
|
||||
let maxScrollPointY = frame.size.height - documentVisibleRect.size.height
|
||||
scrollPoint.y = min(maxScrollPointY, scrollPoint.y)
|
||||
|
||||
let clipView = scrollView.contentView
|
||||
|
||||
let rClipView = NSMakeRect(scrollPoint.x, scrollPoint.y, NSWidth(clipView.bounds), NSHeight(clipView.bounds))
|
||||
|
||||
clipView.animator().bounds = rClipView
|
||||
}
|
||||
|
||||
func scrollToRowIfNotVisible(_ row: Int) {
|
||||
if let followingRow = rowView(atRow: row, makeIfNecessary: false) {
|
||||
if !(visibleRowViews()?.contains(followingRow) ?? false) {
|
||||
scrollTo(row: row, extraHeight: 0)
|
||||
}
|
||||
} else {
|
||||
scrollTo(row: row, extraHeight: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleRowViews() -> [NSTableRowView]? {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView, numberOfRows > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let range = rows(in: scrollView.documentVisibleRect)
|
||||
let ixMax = numberOfRows - 1
|
||||
let ixStart = min(range.location, ixMax)
|
||||
let ixEnd = min(((range.location + range.length) - 1), ixMax)
|
||||
|
||||
var visibleRows = [NSTableRowView]()
|
||||
|
||||
for ixRow in ixStart...ixEnd {
|
||||
if let oneRowView = rowView(atRow: ixRow, makeIfNecessary: false) {
|
||||
visibleRows += [oneRowView]
|
||||
}
|
||||
}
|
||||
|
||||
return visibleRows.isEmpty ? nil : visibleRows
|
||||
}
|
||||
|
||||
func selectRow(_ row: Int) {
|
||||
|
||||
self.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||
}
|
||||
|
||||
func selectRowAndScrollToVisible(_ row: Int) {
|
||||
|
||||
self.selectRow(row)
|
||||
self.scrollRowToVisible(row)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// NSToolbar+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSToolbar {
|
||||
|
||||
func existingItem(withIdentifier identifier: NSToolbarItem.Identifier) -> NSToolbarItem? {
|
||||
return items.first(where: {$0.itemIdentifier == identifier})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// NSView+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 11/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import FoundationExtras
|
||||
|
||||
public extension NSView {
|
||||
|
||||
func asImage() -> NSImage {
|
||||
let rep = bitmapImageRepForCachingDisplay(in: bounds)!
|
||||
cacheDisplay(in: bounds, to: rep)
|
||||
|
||||
let img = NSImage(size: bounds.size)
|
||||
img.addRepresentation(rep)
|
||||
return img
|
||||
}
|
||||
|
||||
func constraintsToMakeSubViewFullSize(_ subview: NSView) -> [NSLayoutConstraint] {
|
||||
|
||||
let leadingConstraint = NSLayoutConstraint(item: subview, attribute: .leading, relatedBy: .equal, toItem: self.safeAreaLayoutGuide, attribute: .leading, multiplier: 1.0, constant: 0.0)
|
||||
let trailingConstraint = NSLayoutConstraint(item: subview, attribute: .trailing, relatedBy: .equal, toItem: self.safeAreaLayoutGuide, attribute: .trailing, multiplier: 1.0, constant: 0.0)
|
||||
let topConstraint = NSLayoutConstraint(item: subview, attribute: .top, relatedBy: .equal, toItem: self.safeAreaLayoutGuide, attribute: .top, multiplier: 1.0, constant: 0.0)
|
||||
let bottomConstraint = NSLayoutConstraint(item: subview, attribute: .bottom, relatedBy: .equal, toItem: self.safeAreaLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: 0.0)
|
||||
return [leadingConstraint, trailingConstraint, topConstraint, bottomConstraint]
|
||||
}
|
||||
|
||||
/// Keeps a subview at same size as receiver.
|
||||
///
|
||||
/// - Parameter subview: The subview to constrain. Must be a descendant of `self`.
|
||||
func addFullSizeConstraints(forSubview subview: NSView) {
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
subview.topAnchor.constraint(equalTo: topAnchor),
|
||||
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
/// Sets the view's frame if it's different from the current frame.
|
||||
///
|
||||
/// - Parameter rect: The new frame.
|
||||
func setFrame(ifNotEqualTo rect: NSRect) {
|
||||
if self.frame != rect {
|
||||
self.frame = rect
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
95
Modules/AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift
Executable file
95
Modules/AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift
Executable file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// NSWindow-Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSWindow {
|
||||
|
||||
var isDisplayingSheet: Bool {
|
||||
|
||||
return attachedSheet != nil
|
||||
}
|
||||
|
||||
func makeFirstResponderUnlessDescendantIsFirstResponder(_ responder: NSResponder) {
|
||||
|
||||
if let fr = firstResponder, fr.hasAncestor(responder) {
|
||||
return
|
||||
}
|
||||
makeFirstResponder(responder)
|
||||
}
|
||||
|
||||
func setPointAndSizeAdjustingForScreen(point: NSPoint, size: NSSize, minimumSize: NSSize) {
|
||||
|
||||
// point.y specifices from the *top* of the screen, even though screen coordinates work from the bottom up. This is for convenience.
|
||||
// The eventual size may be smaller than requested, since the screen may be small, but not smaller than minimumSize.
|
||||
|
||||
guard let screenFrame = screen?.visibleFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let paddingFromScreenEdge: CGFloat = 8.0
|
||||
let x = point.x
|
||||
let y = screenFrame.maxY - point.y
|
||||
|
||||
var width = size.width
|
||||
var height = size.height
|
||||
|
||||
if x + width > screenFrame.maxX {
|
||||
width = max((screenFrame.maxX - x) - paddingFromScreenEdge, minimumSize.width)
|
||||
}
|
||||
if y - height < 0.0 {
|
||||
height = max((screenFrame.maxY - point.y) - paddingFromScreenEdge, minimumSize.height)
|
||||
}
|
||||
|
||||
let frame = NSRect(x: x, y: y, width: width, height: height)
|
||||
setFrame(frame, display: true)
|
||||
setFrameTopLeftPoint(frame.origin)
|
||||
}
|
||||
|
||||
var flippedOrigin: NSPoint? {
|
||||
|
||||
// Screen coordinates start at lower-left.
|
||||
// With this we can use upper-left, like sane people.
|
||||
|
||||
get {
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let flippedPoint = NSPoint(x: frame.origin.x, y: screenFrame.maxY - frame.maxY)
|
||||
return flippedPoint
|
||||
}
|
||||
set {
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return
|
||||
}
|
||||
var point = newValue!
|
||||
point.y = screenFrame.maxY - point.y
|
||||
setFrameTopLeftPoint(point)
|
||||
}
|
||||
}
|
||||
|
||||
func setFlippedOriginAdjustingForScreen(_ point: NSPoint) {
|
||||
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return
|
||||
}
|
||||
|
||||
let paddingFromEdge: CGFloat = 8.0
|
||||
var unflippedPoint = point
|
||||
unflippedPoint.y = (screenFrame.maxY - point.y) - frame.height
|
||||
if unflippedPoint.y < 0 {
|
||||
unflippedPoint.y = paddingFromEdge
|
||||
}
|
||||
if unflippedPoint.x < 0 {
|
||||
unflippedPoint.x = paddingFromEdge
|
||||
}
|
||||
setFrameOrigin(unflippedPoint)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// NSWindowController+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSWindowController {
|
||||
|
||||
var isDisplayingSheet: Bool {
|
||||
|
||||
return window?.isDisplayingSheet ?? false
|
||||
}
|
||||
|
||||
var isOpen: Bool {
|
||||
|
||||
return isWindowLoaded && window!.isVisible
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// PasteboardWriterOwner.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public protocol PasteboardWriterOwner {
|
||||
|
||||
@MainActor var pasteboardWriter: NSPasteboardWriting { get }
|
||||
}
|
||||
#endif
|
||||
65
Modules/AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift
Executable file
65
Modules/AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift
Executable file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// RSToolbarItem.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/16/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public class RSToolbarItem: NSToolbarItem {
|
||||
|
||||
override public func validate() {
|
||||
|
||||
guard let view = view, let _ = view.window else {
|
||||
isEnabled = false
|
||||
return
|
||||
}
|
||||
isEnabled = isValidAsUserInterfaceItem()
|
||||
}
|
||||
}
|
||||
|
||||
private extension RSToolbarItem {
|
||||
|
||||
func isValidAsUserInterfaceItem() -> Bool {
|
||||
|
||||
// Use NSValidatedUserInterfaceItem protocol rather than calling validateToolbarItem:.
|
||||
|
||||
if let target = target as? NSResponder {
|
||||
return validateWithResponder(target) ?? false
|
||||
}
|
||||
|
||||
var responder = view?.window?.firstResponder
|
||||
if responder == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
while(true) {
|
||||
if let validated = validateWithResponder(responder!) {
|
||||
return validated
|
||||
}
|
||||
responder = responder?.nextResponder
|
||||
if responder == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let appDelegate = NSApplication.shared.delegate {
|
||||
if let validated = validateWithResponder(appDelegate) {
|
||||
return validated
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func validateWithResponder(_ responder: NSObjectProtocol) -> Bool? {
|
||||
|
||||
guard responder.responds(to: action), let target = responder as? NSUserInterfaceValidations else {
|
||||
return nil
|
||||
}
|
||||
return target.validateUserInterfaceItem(self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// URLPasteboardWriter.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/28/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
// Takes a string, not a URL, but writes it as a URL (when possible) and as a String.
|
||||
|
||||
@objc public final class URLPasteboardWriter: NSObject, NSPasteboardWriting {
|
||||
|
||||
let urlString: String
|
||||
|
||||
public init(urlString: String) {
|
||||
|
||||
self.urlString = urlString
|
||||
}
|
||||
|
||||
public class func write(urlString: String, to pasteboard: NSPasteboard) {
|
||||
|
||||
pasteboard.clearContents()
|
||||
let writer = URLPasteboardWriter(urlString: urlString)
|
||||
pasteboard.writeObjects([writer])
|
||||
}
|
||||
|
||||
// MARK: - NSPasteboardWriting
|
||||
|
||||
public func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
|
||||
|
||||
if let _ = URL(string: urlString) {
|
||||
return [.URL, .string]
|
||||
}
|
||||
return [.string]
|
||||
}
|
||||
|
||||
public func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
|
||||
|
||||
guard type == .string || type == .URL else {
|
||||
return nil
|
||||
}
|
||||
return urlString
|
||||
}
|
||||
}
|
||||
#endif
|
||||
148
Modules/AppKitExtras/Sources/AppKitExtras/UserApp.swift
Normal file
148
Modules/AppKitExtras/Sources/AppKitExtras/UserApp.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// UserApp.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/14/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Represents an app (the type of app mostly found in /Applications.)
|
||||
///
|
||||
/// The app may or may not be running. It may or may not exist.
|
||||
|
||||
public final class UserApp {
|
||||
|
||||
public let bundleID: String
|
||||
public var existsOnDisk = false
|
||||
|
||||
public var isRunning: Bool {
|
||||
|
||||
updateStatus()
|
||||
if let runningApplication = runningApplication {
|
||||
return !runningApplication.isTerminated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var icon: NSImage? = nil
|
||||
private var path: String? = nil
|
||||
private var runningApplication: NSRunningApplication? = nil
|
||||
|
||||
public init(bundleID: String) {
|
||||
|
||||
self.bundleID = bundleID
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
public func updateStatus() {
|
||||
|
||||
if let runningApplication = runningApplication, runningApplication.isTerminated {
|
||||
self.runningApplication = nil
|
||||
}
|
||||
|
||||
let runningApplications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
|
||||
for app in runningApplications {
|
||||
if let runningApplication = runningApplication {
|
||||
if app == runningApplication {
|
||||
break
|
||||
}
|
||||
}
|
||||
else {
|
||||
if !app.isTerminated {
|
||||
runningApplication = app
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let runningApplication = runningApplication {
|
||||
existsOnDisk = true
|
||||
icon = runningApplication.icon
|
||||
if let bundleURL = runningApplication.bundleURL {
|
||||
path = bundleURL.path
|
||||
}
|
||||
else {
|
||||
path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)?.path
|
||||
}
|
||||
if icon == nil, let path {
|
||||
icon = NSWorkspace.shared.icon(forFile: path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)?.path
|
||||
if icon == nil, let path {
|
||||
icon = NSWorkspace.shared.icon(forFile: path)
|
||||
existsOnDisk = true
|
||||
}
|
||||
else {
|
||||
existsOnDisk = false
|
||||
icon = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func launchIfNeeded() async -> Bool {
|
||||
|
||||
// Return true if already running.
|
||||
// Return true if not running and successfully gets launched.
|
||||
|
||||
updateStatus()
|
||||
if isRunning {
|
||||
return true
|
||||
}
|
||||
|
||||
guard existsOnDisk, let path = path else {
|
||||
return false
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: path)
|
||||
|
||||
do {
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.promptsUserIfNeeded = true
|
||||
|
||||
let app = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration)
|
||||
runningApplication = app
|
||||
|
||||
if app.isFinishedLaunching {
|
||||
return true
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(1)) // Give the app time to launch. This is ugly.
|
||||
if app.isFinishedLaunching {
|
||||
return true
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(1)) // Give it some *more* time.
|
||||
return true
|
||||
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func bringToFront() -> Bool {
|
||||
|
||||
// Activates the app, ignoring other apps.
|
||||
// Does not automatically launch the app first.
|
||||
|
||||
updateStatus()
|
||||
return runningApplication?.activate() ?? false
|
||||
}
|
||||
|
||||
public func targetDescriptor() -> NSAppleEventDescriptor? {
|
||||
|
||||
// Requires that the app has previously been launched.
|
||||
|
||||
updateStatus()
|
||||
guard let runningApplication = runningApplication, !runningApplication.isTerminated else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSAppleEventDescriptor(runningApplication: runningApplication)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// NSMenuExtensionsTests.swift
|
||||
//
|
||||
//
|
||||
// Created by Brent Simmons on 5/18/24.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import XCTest
|
||||
import AppKitExtras
|
||||
|
||||
final class NSMenuExtensionsTests: XCTestCase {
|
||||
|
||||
// MARK: - Test addSeparatorIfNeeded
|
||||
|
||||
func testAddSeparatorIfNeeded_NoSeparator() {
|
||||
|
||||
let menu = NSMenu(title: "Test")
|
||||
menu.addItem(menuItemForTesting())
|
||||
menu.addItem(menuItemForTesting())
|
||||
menu.addSeparatorIfNeeded()
|
||||
|
||||
XCTAssertTrue(menu.items.last!.isSeparatorItem)
|
||||
}
|
||||
|
||||
func testAddSeparatorIfNeeded_EmptyMenu() {
|
||||
|
||||
let menu = NSMenu(title: "Test")
|
||||
menu.addSeparatorIfNeeded()
|
||||
|
||||
// A separator should not be added to a menu with 0 items,
|
||||
// since a menu should never start with a separator item.
|
||||
XCTAssertEqual(menu.items.count, 0)
|
||||
}
|
||||
|
||||
func testAddSeparatorIfNeeded_HasSeparator() {
|
||||
|
||||
let menu = NSMenu(title: "Test")
|
||||
menu.addItem(menuItemForTesting())
|
||||
menu.addItem(menuItemForTesting())
|
||||
menu.addItem(.separator())
|
||||
|
||||
let menuItemsCount = menu.items.count
|
||||
|
||||
XCTAssertTrue(menu.items.last!.isSeparatorItem)
|
||||
|
||||
// Should not get added — last item is already separator
|
||||
menu.addSeparatorIfNeeded()
|
||||
// Make sure last item is still separator
|
||||
XCTAssertTrue(menu.items.last!.isSeparatorItem)
|
||||
// Count should be same as before calling `addSeparatorIfNeeded()`
|
||||
XCTAssertEqual(menu.items.count, menuItemsCount)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSMenuExtensionsTests {
|
||||
|
||||
private func menuItemForTesting(_ title: String = "Test NSMenuItem") -> NSMenuItem {
|
||||
NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
8
Modules/ArticleExtractor/.gitignore
vendored
Normal file
8
Modules/ArticleExtractor/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
30
Modules/ArticleExtractor/Package.swift
Normal file
30
Modules/ArticleExtractor/Package.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
// swift-tools-version: 5.10
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ArticleExtractor",
|
||||
platforms: [.macOS(.v14), .iOS(.v17)],
|
||||
products: [
|
||||
.library(
|
||||
name: "ArticleExtractor",
|
||||
targets: ["ArticleExtractor"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../FoundationExtras")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ArticleExtractor",
|
||||
dependencies: [
|
||||
"FoundationExtras",
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ArticleExtractorTests",
|
||||
dependencies: ["ArticleExtractor"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// ArticleExtractor.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 9/18/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FoundationExtras
|
||||
|
||||
public enum ArticleExtractorState: Sendable {
|
||||
case ready
|
||||
case processing
|
||||
case failedToParse
|
||||
case complete
|
||||
case cancelled
|
||||
}
|
||||
|
||||
public protocol ArticleExtractorDelegate {
|
||||
|
||||
@MainActor func articleExtractionDidFail(with: Error)
|
||||
@MainActor func articleExtractionDidComplete(extractedArticle: ExtractedArticle)
|
||||
}
|
||||
|
||||
@MainActor public final class ArticleExtractor {
|
||||
|
||||
public var state: ArticleExtractorState!
|
||||
public var article: ExtractedArticle?
|
||||
public var delegate: ArticleExtractorDelegate?
|
||||
public let articleLink: String?
|
||||
|
||||
private var dataTask: URLSessionDataTask? = nil
|
||||
private let url: URL!
|
||||
|
||||
public init?(_ articleLink: String, clientID: String, clientSecret: String) {
|
||||
self.articleLink = articleLink
|
||||
|
||||
let clientURL = "https://extract.feedbin.com/parser"
|
||||
let username = clientID
|
||||
let signature = articleLink.hmacUsingSHA1(key: clientSecret)
|
||||
|
||||
if let base64URL = articleLink.data(using: .utf8)?.base64EncodedString() {
|
||||
let fullURL = "\(clientURL)/\(username)/\(signature)?base64_url=\(base64URL)"
|
||||
if let url = URL(string: fullURL) {
|
||||
self.url = url
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func process() {
|
||||
|
||||
state = .processing
|
||||
|
||||
dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
if let error {
|
||||
self.noteDidFail(error: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data else {
|
||||
self.noteDidFail(error: URLError(.cannotDecodeContentData))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let article = try decodeArticle(data: data)
|
||||
self.article = article
|
||||
|
||||
if article.content == nil {
|
||||
self.noteDidFail(error: URLError(.cannotDecodeContentData))
|
||||
} else {
|
||||
self.noteDidComplete(article: article)
|
||||
}
|
||||
} catch {
|
||||
self.noteDidFail(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataTask!.resume()
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
state = .cancelled
|
||||
dataTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ArticleExtractor {
|
||||
|
||||
func decodeArticle(data: Data) throws -> ExtractedArticle {
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let article = try decoder.decode(ExtractedArticle.self, from: data)
|
||||
return article
|
||||
}
|
||||
|
||||
func noteDidFail(error: Error) {
|
||||
|
||||
state = .failedToParse
|
||||
delegate?.articleExtractionDidFail(with: error)
|
||||
}
|
||||
|
||||
func noteDidComplete(article: ExtractedArticle) {
|
||||
|
||||
state = .complete
|
||||
delegate?.articleExtractionDidComplete(extractedArticle: article)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// ExtractedArticle.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 9/18/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ExtractedArticle: Codable, Equatable, Sendable {
|
||||
|
||||
public let title: String?
|
||||
public let author: String?
|
||||
public let datePublished: String?
|
||||
public let dek: String?
|
||||
public let leadImageURL: String?
|
||||
public let content: String?
|
||||
public let nextPageURL: String?
|
||||
public let url: String?
|
||||
public let domain: String?
|
||||
public let excerpt: String?
|
||||
public let wordCount: Int?
|
||||
public let direction: String?
|
||||
public let totalPages: Int?
|
||||
public let renderedPages: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title = "title"
|
||||
case author = "author"
|
||||
case datePublished = "date_published"
|
||||
case dek = "dek"
|
||||
case leadImageURL = "lead_image_url"
|
||||
case content = "content"
|
||||
case nextPageURL = "next_page_url"
|
||||
case url = "url"
|
||||
case domain = "domain"
|
||||
case excerpt = "excerpt"
|
||||
case wordCount = "word_count"
|
||||
case direction = "direction"
|
||||
case totalPages = "total_pages"
|
||||
case renderedPages = "rendered_pages"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import XCTest
|
||||
@testable import ArticleExtractor
|
||||
|
||||
final class ArticleExtractorTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// XCTest Documentation
|
||||
// https://developer.apple.com/documentation/xctest
|
||||
|
||||
// Defining Test Cases and Test Methods
|
||||
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
||||
}
|
||||
}
|
||||
5
Modules/Articles/.gitignore
vendored
Normal file
5
Modules/Articles/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Articles"
|
||||
BuildableName = "Articles"
|
||||
BlueprintName = "Articles"
|
||||
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:../Account">
|
||||
</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 = "Articles"
|
||||
BuildableName = "Articles"
|
||||
BlueprintName = "Articles"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user