Move local modules into a folder named Modules.

This commit is contained in:
Brent Simmons
2024-07-06 21:07:05 -07:00
parent 14bcef0f9a
commit d50b5818ac
491 changed files with 76 additions and 52 deletions

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

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

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "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>

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
//
// AccountBehaviors.swift
// Account
//
// Created by Maurice Parker on 9/20/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/**
Account specific behaviors are used to support different sync services. These sync
services don't all act the same and we need to reflect their differences in the
user interface as much as possible. For example some sync services don't allow
feeds to be in the root folder of the account.
*/
public typealias AccountBehaviors = [AccountBehavior]
public enum AccountBehavior: Equatable {
/**
Account doesn't support copies of a feed that are in a folder to be made to the root folder.
*/
case disallowFeedCopyInRootFolder
/**
Account doesn't support feeds in the root folder.
*/
case disallowFeedInRootFolder
/**
Account doesn't support a feed being in more than one folder.
*/
case disallowFeedInMultipleFolders
/**
Account doesn't support folders
*/
case disallowFolderManagement
/**
Account doesn't support OPML imports
*/
case disallowOPMLImports
/**
Account doesn't allow mark as read after a period of days
*/
case disallowMarkAsUnreadAfterPeriod(Int)
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,49 @@
//
// ContainerPath.swift
// NetNewsWire
//
// Created by Brent Simmons on 11/4/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
import Foundation
// Used to identify the parent of an object.
// Mainly used with deleting objects and undo/redo.
// Especially redo. The idea is to put something back in the right place.
@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)
}
}

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

View 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 dont store it
// as part of feed metadata.
public var iconURL: String? {
get {
return metadata.iconURL
}
set {
metadata.iconURL = newValue
}
}
// Note: this is available only if the favicon URL was available in the feed.
// The favicon URL is a JSON-Feed-only feature.
// Otherwise we find a favicon URL via other means, but we dont store it
// as part of feed metadata.
public var faviconURL: String? {
get {
return metadata.faviconURL
}
set {
metadata.faviconURL = newValue
}
}
public var name: String? {
didSet {
if name != oldValue {
postDisplayNameDidChangeNotification()
}
}
}
public var authors: Set<Author>? {
get {
if let authorsArray = metadata.authors {
return Set(authorsArray)
}
return nil
}
set {
if let authorsSet = newValue {
metadata.authors = Array(authorsSet)
}
else {
metadata.authors = nil
}
}
}
public var editedName: String? {
// Dont let editedName == ""
get {
guard let s = metadata.editedName, !s.isEmpty else {
return nil
}
return s
}
set {
if newValue != editedName {
if let valueToSet = newValue, !valueToSet.isEmpty {
metadata.editedName = valueToSet
}
else {
metadata.editedName = nil
}
postDisplayNameDidChangeNotification()
}
}
}
public var conditionalGetInfo: HTTPConditionalGetInfo? {
get {
return metadata.conditionalGetInfo
}
set {
metadata.conditionalGetInfo = newValue
}
}
public var 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
// Dont use nameForDisplay because that can result in a feed name "Untitled" written to disk,
// which NetNewsWire may take later to be the actual name.
var nameToUse = editedName
if nameToUse == nil {
nameToUse = name
}
if nameToUse == nil {
nameToUse = ""
}
let escapedName = nameToUse!.escapingSpecialXMLCharacters
var escapedHomePageURL = ""
if let homePageURL = homePageURL {
escapedHomePageURL = homePageURL.escapingSpecialXMLCharacters
}
let escapedFeedURL = url.escapingSpecialXMLCharacters
var s = "<outline text=\"\(escapedName)\" title=\"\(escapedName)\" description=\"\" type=\"rss\" version=\"RSS\" htmlUrl=\"\(escapedHomePageURL)\" xmlUrl=\"\(escapedFeedURL)\"/>\n"
s = s.prepending(tabCount: indentLevel)
return s
}
}
extension Set where Element == Feed {
@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
})
}
}

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

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

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

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

View 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 doesnt have a name, so it wont 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)
}
}
}
}

View 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]))
}
// Doesnt actually fetch unread articles. Fetches whatever articleID it is asked to fetch.
public func fetchUnreadArticles() async throws -> Set<Article> {
try await fetchArticles()
}
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

View 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.")
// }
//}

View File

@@ -0,0 +1,7 @@
import XCTest
import AccountTests
var tests = [XCTestCaseEntry]()
tests += AccountTests.allTests()
XCTMain(tests)

8
Modules/AppKitExtras/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

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

View File

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

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

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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, were 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

View File

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

View 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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View 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

View File

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

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "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