mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Convert remainder of iOS to using folders instead of groups.
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
//
|
||||
// AddWebFeedIntentHandler.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 10/18/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
|
||||
public enum AddWebFeedIntentHandlerError: LocalizedError {
|
||||
|
||||
case communicationFailure
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .communicationFailure:
|
||||
return NSLocalizedString("Unable to communicate with NetNewsWire.", comment: "Communication failure")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling {
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func resolveUrl(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedUrlResolutionResult) -> Void) {
|
||||
guard let url = intent.url else {
|
||||
completion(.unsupported(forReason: .required))
|
||||
return
|
||||
}
|
||||
completion(.success(with: url))
|
||||
}
|
||||
|
||||
public func provideAccountNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) {
|
||||
guard let extensionContainers = ExtensionContainersFile.read() else {
|
||||
completion(nil, AddWebFeedIntentHandlerError.communicationFailure)
|
||||
return
|
||||
}
|
||||
|
||||
let accountNames = extensionContainers.accounts.map { $0.name }
|
||||
completion(accountNames, nil)
|
||||
}
|
||||
|
||||
public func resolveAccountName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedAccountNameResolutionResult) -> Void) {
|
||||
guard let accountName = intent.accountName else {
|
||||
completion(AddWebFeedAccountNameResolutionResult.notRequired())
|
||||
return
|
||||
}
|
||||
|
||||
guard let extensionContainers = ExtensionContainersFile.read() else {
|
||||
completion(.unsupported(forReason: .communication))
|
||||
return
|
||||
}
|
||||
|
||||
if extensionContainers.findAccount(forName: accountName) == nil {
|
||||
completion(.unsupported(forReason: .invalid))
|
||||
} else {
|
||||
completion(.success(with: accountName))
|
||||
}
|
||||
}
|
||||
|
||||
public func provideFolderNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) {
|
||||
guard let extensionContainers = ExtensionContainersFile.read() else {
|
||||
completion(nil, AddWebFeedIntentHandlerError.communicationFailure)
|
||||
return
|
||||
}
|
||||
|
||||
guard let accountName = intent.accountName, let account = extensionContainers.findAccount(forName: accountName) else {
|
||||
completion([String](), nil)
|
||||
return
|
||||
}
|
||||
|
||||
let folderNames = account.folders.map { $0.name }
|
||||
completion(folderNames, nil)
|
||||
}
|
||||
|
||||
public func resolveFolderName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedFolderNameResolutionResult) -> Void) {
|
||||
guard let accountName = intent.accountName, let folderName = intent.folderName else {
|
||||
completion(AddWebFeedFolderNameResolutionResult.notRequired())
|
||||
return
|
||||
}
|
||||
|
||||
guard let extensionContainers = ExtensionContainersFile.read() else {
|
||||
completion(.unsupported(forReason: .communication))
|
||||
return
|
||||
}
|
||||
|
||||
guard let account = extensionContainers.findAccount(forName: accountName) else {
|
||||
completion(.unsupported(forReason: .invalid))
|
||||
return
|
||||
}
|
||||
|
||||
if account.findFolder(forName: folderName) == nil {
|
||||
completion(.unsupported(forReason: .invalid))
|
||||
} else {
|
||||
completion(.success(with: folderName))
|
||||
}
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
public func handle(intent: AddWebFeedIntent, completion: @escaping (AddWebFeedIntentResponse) -> Void) {
|
||||
guard let url = intent.url, let extensionContainers = ExtensionContainersFile.read() else {
|
||||
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let account: ExtensionAccount? = {
|
||||
if let accountName = intent.accountName {
|
||||
return extensionContainers.findAccount(forName: accountName)
|
||||
} else {
|
||||
return extensionContainers.accounts.first
|
||||
}
|
||||
}()
|
||||
|
||||
guard let validAccount = account else {
|
||||
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let container: ExtensionContainer? = {
|
||||
if let folderName = intent.folderName {
|
||||
return validAccount.findFolder(forName: folderName)
|
||||
} else {
|
||||
return validAccount
|
||||
}
|
||||
}()
|
||||
|
||||
guard let validContainer = container, let containerID = validContainer.containerID else {
|
||||
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let request = ExtensionFeedAddRequest(name: nil, feedURL: url, destinationContainerID: containerID)
|
||||
ExtensionFeedAddRequestFile.save(request)
|
||||
completion(AddWebFeedIntentResponse(code: .success, userActivity: nil))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>INEnums</key>
|
||||
<array/>
|
||||
<key>INIntentDefinitionModelVersion</key>
|
||||
<string>1.1</string>
|
||||
<key>INIntentDefinitionNamespace</key>
|
||||
<string>U6u7RF</string>
|
||||
<key>INIntentDefinitionSystemVersion</key>
|
||||
<string>19D76</string>
|
||||
<key>INIntentDefinitionToolsBuildVersion</key>
|
||||
<string>11B53</string>
|
||||
<key>INIntentDefinitionToolsVersion</key>
|
||||
<string>11.2.1</string>
|
||||
<key>INIntents</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentCategory</key>
|
||||
<string>create</string>
|
||||
<key>INIntentConfigurable</key>
|
||||
<true/>
|
||||
<key>INIntentDescription</key>
|
||||
<string>Add a web feed</string>
|
||||
<key>INIntentDescriptionID</key>
|
||||
<string>IuAbef</string>
|
||||
<key>INIntentIneligibleForSuggestions</key>
|
||||
<true/>
|
||||
<key>INIntentInput</key>
|
||||
<string>url</string>
|
||||
<key>INIntentKeyParameter</key>
|
||||
<string>url</string>
|
||||
<key>INIntentLastParameterTag</key>
|
||||
<integer>4</integer>
|
||||
<key>INIntentManagedParameterCombinations</key>
|
||||
<dict>
|
||||
<key>url,accountName</key>
|
||||
<dict>
|
||||
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
|
||||
<true/>
|
||||
<key>INIntentParameterCombinationTitle</key>
|
||||
<string>Add ${url} to ${accountName}</string>
|
||||
<key>INIntentParameterCombinationTitleID</key>
|
||||
<string>kaKsEY</string>
|
||||
<key>INIntentParameterCombinationUpdatesLinked</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>url,accountName,folderName</key>
|
||||
<dict>
|
||||
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
|
||||
<true/>
|
||||
<key>INIntentParameterCombinationTitle</key>
|
||||
<string>Add ${url} to ${folderName} in ${accountName}</string>
|
||||
<key>INIntentParameterCombinationTitleID</key>
|
||||
<string>dkSFD2</string>
|
||||
<key>INIntentParameterCombinationUpdatesLinked</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>INIntentName</key>
|
||||
<string>AddWebFeed</string>
|
||||
<key>INIntentParameters</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterDisplayName</key>
|
||||
<string>URL</string>
|
||||
<key>INIntentParameterDisplayNameID</key>
|
||||
<string>BCHr23</string>
|
||||
<key>INIntentParameterDisplayPriority</key>
|
||||
<integer>1</integer>
|
||||
<key>INIntentParameterName</key>
|
||||
<string>url</string>
|
||||
<key>INIntentParameterPromptDialogs</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>What is the ${url} you would like add?</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>jLLidQ</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>Primary</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>INIntentParameterSupportsResolution</key>
|
||||
<true/>
|
||||
<key>INIntentParameterTag</key>
|
||||
<integer>2</integer>
|
||||
<key>INIntentParameterType</key>
|
||||
<string>URL</string>
|
||||
<key>INIntentParameterUnsupportedReasons</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterUnsupportedReasonCode</key>
|
||||
<string>required</string>
|
||||
<key>INIntentParameterUnsupportedReasonCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterUnsupportedReasonFormatString</key>
|
||||
<string>You must supply a URL.</string>
|
||||
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
|
||||
<string>4xjRes</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterCustomDisambiguation</key>
|
||||
<true/>
|
||||
<key>INIntentParameterDisplayName</key>
|
||||
<string>Account Name</string>
|
||||
<key>INIntentParameterDisplayNameID</key>
|
||||
<string>CSrgUY</string>
|
||||
<key>INIntentParameterDisplayPriority</key>
|
||||
<integer>2</integer>
|
||||
<key>INIntentParameterMetadata</key>
|
||||
<dict>
|
||||
<key>INIntentParameterMetadataCapitalization</key>
|
||||
<string>Sentences</string>
|
||||
</dict>
|
||||
<key>INIntentParameterName</key>
|
||||
<string>accountName</string>
|
||||
<key>INIntentParameterPromptDialogs</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>Primary</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>There are ${count} options matching ‘${accountName}’.</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>IbqUVS</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>DisambiguationIntroduction</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>Which one?</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>fWs3li</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>DisambiguationSelection</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>Just to confirm, you wanted ‘${accountName}’?</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>HHiZUh</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>Confirmation</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>INIntentParameterSupportsDynamicEnumeration</key>
|
||||
<true/>
|
||||
<key>INIntentParameterSupportsResolution</key>
|
||||
<true/>
|
||||
<key>INIntentParameterTag</key>
|
||||
<integer>3</integer>
|
||||
<key>INIntentParameterType</key>
|
||||
<string>String</string>
|
||||
<key>INIntentParameterUnsupportedReasons</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterUnsupportedReasonCode</key>
|
||||
<string>invalid</string>
|
||||
<key>INIntentParameterUnsupportedReasonCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterUnsupportedReasonFormatString</key>
|
||||
<string>A valid Account Name is required.</string>
|
||||
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
|
||||
<string>JGkCuS</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterUnsupportedReasonCode</key>
|
||||
<string>communication</string>
|
||||
<key>INIntentParameterUnsupportedReasonCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterUnsupportedReasonFormatString</key>
|
||||
<string>Unable to communicate with NetNewsWire.</string>
|
||||
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
|
||||
<string>uSfloN</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterCustomDisambiguation</key>
|
||||
<true/>
|
||||
<key>INIntentParameterDisplayName</key>
|
||||
<string>Folder Name</string>
|
||||
<key>INIntentParameterDisplayNameID</key>
|
||||
<string>zXhMPF</string>
|
||||
<key>INIntentParameterDisplayPriority</key>
|
||||
<integer>3</integer>
|
||||
<key>INIntentParameterMetadata</key>
|
||||
<dict>
|
||||
<key>INIntentParameterMetadataCapitalization</key>
|
||||
<string>Sentences</string>
|
||||
</dict>
|
||||
<key>INIntentParameterName</key>
|
||||
<string>folderName</string>
|
||||
<key>INIntentParameterPromptDialogs</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>Primary</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>There are ${count} options matching ‘${folderName}’.</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>5CYbGL</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>DisambiguationIntroduction</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>Which one?</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>gEzXaM</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>DisambiguationSelection</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterPromptDialogCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterPromptDialogFormatString</key>
|
||||
<string>Just to confirm, you wanted ‘${folderName}’?</string>
|
||||
<key>INIntentParameterPromptDialogFormatStringID</key>
|
||||
<string>k5GTo0</string>
|
||||
<key>INIntentParameterPromptDialogType</key>
|
||||
<string>Confirmation</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>INIntentParameterRelationship</key>
|
||||
<dict>
|
||||
<key>INIntentParameterRelationshipParentName</key>
|
||||
<string>accountName</string>
|
||||
<key>INIntentParameterRelationshipPredicateName</key>
|
||||
<string>HasAnyValue</string>
|
||||
</dict>
|
||||
<key>INIntentParameterSupportsDynamicEnumeration</key>
|
||||
<true/>
|
||||
<key>INIntentParameterSupportsResolution</key>
|
||||
<true/>
|
||||
<key>INIntentParameterTag</key>
|
||||
<integer>4</integer>
|
||||
<key>INIntentParameterType</key>
|
||||
<string>String</string>
|
||||
<key>INIntentParameterUnsupportedReasons</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentParameterUnsupportedReasonCode</key>
|
||||
<string>invalid</string>
|
||||
<key>INIntentParameterUnsupportedReasonCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterUnsupportedReasonFormatString</key>
|
||||
<string>A valid Folder Name is required.</string>
|
||||
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
|
||||
<string>ef5kBt</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentParameterUnsupportedReasonCode</key>
|
||||
<string>communication</string>
|
||||
<key>INIntentParameterUnsupportedReasonCustom</key>
|
||||
<true/>
|
||||
<key>INIntentParameterUnsupportedReasonFormatString</key>
|
||||
<string>Unable to communicate with NetNewsWire.</string>
|
||||
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
|
||||
<string>ExjqcE</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>INIntentResponse</key>
|
||||
<dict>
|
||||
<key>INIntentResponseCodes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>INIntentResponseCodeName</key>
|
||||
<string>success</string>
|
||||
<key>INIntentResponseCodeSuccess</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>INIntentResponseCodeName</key>
|
||||
<string>failure</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
<key>INIntentTitle</key>
|
||||
<string>Add Web Feed</string>
|
||||
<key>INIntentTitleID</key>
|
||||
<string>oV681v</string>
|
||||
<key>INIntentType</key>
|
||||
<string>Custom</string>
|
||||
<key>INIntentVerb</key>
|
||||
<string>Add</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>INTypes</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,30 +0,0 @@
|
||||
"4xjRes" = "You must supply a URL.";
|
||||
|
||||
"8Dh9Yy" = "No feed was found at the specified URL.";
|
||||
|
||||
"BCHr23" = "URL";
|
||||
|
||||
"CSrgUY" = "Account Name";
|
||||
|
||||
"HHiZUh" = "Just to confirm, you wanted ‘${accountName}’?";
|
||||
|
||||
"IbqUVS" = "There are ${count} options matching ‘${accountName}’.";
|
||||
|
||||
"IuAbef" = "Add a feed";
|
||||
|
||||
"JGkCuS" = "An account name is required.";
|
||||
|
||||
"UGGPkp" = "You are already subscribed to this feed in this account.";
|
||||
|
||||
"dkSFD2" = "Add${url}to ${accountName}";
|
||||
|
||||
"drQfaI" = "No feed was found at the specified URL.";
|
||||
|
||||
"fWs3li" = "Which one?";
|
||||
|
||||
"jLLidQ" = "What is the ${url}you would like add?";
|
||||
|
||||
"oV681v" = "Add Feed";
|
||||
|
||||
"srME8b" = "You are already subscribed to this feed in this account.";
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
//
|
||||
// NavigationModelController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 4/21/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Account
|
||||
import Articles
|
||||
import RSCore
|
||||
import RSTree
|
||||
|
||||
public extension Notification.Name {
|
||||
static let MasterSelectionDidChange = Notification.Name(rawValue: "MasterSelectionDidChange")
|
||||
static let BackingStoresDidRebuild = Notification.Name(rawValue: "BackingStoresDidRebuild")
|
||||
static let ArticlesReinitialized = Notification.Name(rawValue: "ArticlesReinitialized")
|
||||
static let ArticleDataDidChange = Notification.Name(rawValue: "ArticleDataDidChange")
|
||||
static let ArticlesDidChange = Notification.Name(rawValue: "ArticlesDidChange")
|
||||
static let ArticleSelectionDidChange = Notification.Name(rawValue: "ArticleSelectionDidChange")
|
||||
}
|
||||
|
||||
class NavigationStateController {
|
||||
|
||||
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5)
|
||||
|
||||
private var articleRowMap = [String: Int]() // articleID: rowIndex
|
||||
|
||||
private var animatingChanges = false
|
||||
private var expandedNodes = [Node]()
|
||||
private var shadowTable = [[Node]]()
|
||||
|
||||
private var sortDirection = AppDefaults.timelineSortDirection {
|
||||
didSet {
|
||||
if sortDirection != oldValue {
|
||||
sortDirectionDidChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let treeControllerDelegate = FeedTreeControllerDelegate()
|
||||
lazy var treeController: TreeController = {
|
||||
return TreeController(delegate: treeControllerDelegate)
|
||||
}()
|
||||
|
||||
var rootNode: Node {
|
||||
return treeController.rootNode
|
||||
}
|
||||
|
||||
var numberOfSections: Int {
|
||||
return shadowTable.count
|
||||
}
|
||||
|
||||
var currentMasterIndexPath: IndexPath? {
|
||||
didSet {
|
||||
guard let ip = currentMasterIndexPath, let node = nodeFor(ip) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
if let fetcher = node.representedObject as? ArticleFetcher {
|
||||
timelineFetcher = fetcher
|
||||
}
|
||||
NotificationCenter.default.post(name: .MasterSelectionDidChange, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var timelineName: String? {
|
||||
return (timelineFetcher as? DisplayNameProvider)?.nameForDisplay
|
||||
}
|
||||
|
||||
var timelineFetcher: ArticleFetcher? {
|
||||
didSet {
|
||||
currentArticleIndexPath = nil
|
||||
if timelineFetcher is Feed {
|
||||
showFeedNames = false
|
||||
} else {
|
||||
showFeedNames = true
|
||||
}
|
||||
fetchArticles()
|
||||
NotificationCenter.default.post(name: .ArticlesReinitialized, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var showFeedNames = false
|
||||
var showAvatars = false
|
||||
|
||||
var isPrevArticleAvailable: Bool {
|
||||
guard let indexPath = currentArticleIndexPath else {
|
||||
return false
|
||||
}
|
||||
return indexPath.row > 0
|
||||
}
|
||||
|
||||
var isNextArticleAvailable: Bool {
|
||||
guard let indexPath = currentArticleIndexPath else {
|
||||
return false
|
||||
}
|
||||
return indexPath.row + 1 < articles.count
|
||||
}
|
||||
|
||||
var prevArticleIndexPath: IndexPath? {
|
||||
guard let indexPath = currentArticleIndexPath else {
|
||||
return nil
|
||||
}
|
||||
return IndexPath(row: indexPath.row - 1, section: indexPath.section)
|
||||
}
|
||||
|
||||
var nextArticleIndexPath: IndexPath? {
|
||||
guard let indexPath = currentArticleIndexPath else {
|
||||
return nil
|
||||
}
|
||||
return IndexPath(row: indexPath.row + 1, section: indexPath.section)
|
||||
}
|
||||
|
||||
var firstUnreadArticleIndexPath: IndexPath? {
|
||||
for (row, article) in articles.enumerated() {
|
||||
if !article.status.read {
|
||||
return IndexPath(row: row, section: 0)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentArticle: Article? {
|
||||
if let indexPath = currentArticleIndexPath {
|
||||
return articles[indexPath.row]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentArticleIndexPath: IndexPath? {
|
||||
didSet {
|
||||
if currentArticleIndexPath != oldValue {
|
||||
NotificationCenter.default.post(name: .ArticleSelectionDidChange, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var articles = ArticleArray() {
|
||||
didSet {
|
||||
if articles == oldValue {
|
||||
return
|
||||
}
|
||||
if articles.representSameArticlesInSameOrder(as: oldValue) {
|
||||
articleRowMap = [String: Int]()
|
||||
NotificationCenter.default.post(name: .ArticleDataDidChange, object: self, userInfo: nil)
|
||||
return
|
||||
}
|
||||
updateShowAvatars()
|
||||
articleRowMap = [String: Int]()
|
||||
NotificationCenter.default.post(name: .ArticlesDidChange, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var isTimelineUnreadAvailable: Bool {
|
||||
if let unreadProvider = timelineFetcher as? UnreadCountProvider {
|
||||
return unreadProvider.unreadCount > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isAnyUnreadAvailable: Bool {
|
||||
return appDelegate.unreadCount > 0
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
for section in treeController.rootNode.childNodes {
|
||||
expandedNodes.append(section)
|
||||
shadowTable.append([Node]())
|
||||
}
|
||||
|
||||
rebuildShadowTable()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .AccountsDidChange, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
@objc func containerChildrenDidChange(_ note: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func batchUpdateDidPerform(_ notification: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func displayNameDidChange(_ note: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func accountStateDidChange(_ note: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func accountsDidChange(_ note: Notification) {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func userDefaultsDidChange(_ note: Notification) {
|
||||
self.sortDirection = AppDefaults.timelineSortDirection
|
||||
}
|
||||
|
||||
@objc func accountDidDownloadArticles(_ note: Notification) {
|
||||
|
||||
guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set<Feed> else {
|
||||
return
|
||||
}
|
||||
|
||||
let shouldFetchAndMergeArticles = timelineFetcherContainsAnyFeed(feeds) || timelineFetcherContainsAnyPseudoFeed()
|
||||
if shouldFetchAndMergeArticles {
|
||||
queueFetchAndMergeArticles()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func beginUpdates() {
|
||||
animatingChanges = true
|
||||
}
|
||||
|
||||
func endUpdates() {
|
||||
animatingChanges = false
|
||||
}
|
||||
|
||||
func rowsInSection(_ section: Int) -> Int {
|
||||
return shadowTable[section].count
|
||||
}
|
||||
|
||||
func rebuildShadowTable() {
|
||||
|
||||
shadowTable = [[Node]]()
|
||||
|
||||
for i in 0..<treeController.rootNode.numberOfChildNodes {
|
||||
|
||||
var result = [Node]()
|
||||
|
||||
if let nodes = treeController.rootNode.childAtIndex(i)?.childNodes {
|
||||
for node in nodes {
|
||||
result.append(node)
|
||||
if expandedNodes.contains(node) {
|
||||
for child in node.childNodes {
|
||||
result.append(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shadowTable.append(result)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func isExpanded(_ node: Node) -> Bool {
|
||||
return expandedNodes.contains(node)
|
||||
}
|
||||
|
||||
func nodeFor(_ indexPath: IndexPath) -> Node? {
|
||||
guard indexPath.section < shadowTable.count || indexPath.row < shadowTable[indexPath.section].count else {
|
||||
return nil
|
||||
}
|
||||
return shadowTable[indexPath.section][indexPath.row]
|
||||
}
|
||||
|
||||
func indexPathFor(_ node: Node) -> IndexPath? {
|
||||
for i in 0..<shadowTable.count {
|
||||
if let row = shadowTable[i].firstIndex(of: node) {
|
||||
return IndexPath(row: row, section: i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expand(section: Int, completion: ([IndexPath]) -> ()) {
|
||||
|
||||
guard let expandNode = treeController.rootNode.childAtIndex(section) else {
|
||||
return
|
||||
}
|
||||
expandedNodes.append(expandNode)
|
||||
|
||||
animatingChanges = true
|
||||
|
||||
var indexPathsToInsert = [IndexPath]()
|
||||
var i = 0
|
||||
|
||||
func addNode(_ node: Node) {
|
||||
indexPathsToInsert.append(IndexPath(row: i, section: section))
|
||||
shadowTable[section].insert(node, at: i)
|
||||
i = i + 1
|
||||
}
|
||||
|
||||
for child in expandNode.childNodes {
|
||||
addNode(child)
|
||||
if expandedNodes.contains(child) {
|
||||
for gChild in child.childNodes {
|
||||
addNode(gChild)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion(indexPathsToInsert)
|
||||
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
func expand(_ indexPath: IndexPath, completion: ([IndexPath]) -> ()) {
|
||||
|
||||
let expandNode = shadowTable[indexPath.section][indexPath.row]
|
||||
expandedNodes.append(expandNode)
|
||||
|
||||
animatingChanges = true
|
||||
|
||||
var indexPathsToInsert = [IndexPath]()
|
||||
for i in 0..<expandNode.childNodes.count {
|
||||
if let child = expandNode.childAtIndex(i) {
|
||||
let nextIndex = indexPath.row + i + 1
|
||||
indexPathsToInsert.append(IndexPath(row: nextIndex, section: indexPath.section))
|
||||
shadowTable[indexPath.section].insert(child, at: nextIndex)
|
||||
}
|
||||
}
|
||||
|
||||
completion(indexPathsToInsert)
|
||||
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
func collapse(section: Int, completion: ([IndexPath]) -> ()) {
|
||||
|
||||
animatingChanges = true
|
||||
|
||||
guard let collapseNode = treeController.rootNode.childAtIndex(section) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let removeNode = expandedNodes.firstIndex(of: collapseNode) {
|
||||
expandedNodes.remove(at: removeNode)
|
||||
}
|
||||
|
||||
var indexPathsToRemove = [IndexPath]()
|
||||
for i in 0..<shadowTable[section].count {
|
||||
indexPathsToRemove.append(IndexPath(row: i, section: section))
|
||||
}
|
||||
shadowTable[section] = [Node]()
|
||||
|
||||
completion(indexPathsToRemove)
|
||||
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
func collapse(_ indexPath: IndexPath, completion: ([IndexPath]) -> ()) {
|
||||
|
||||
animatingChanges = true
|
||||
|
||||
let collapseNode = shadowTable[indexPath.section][indexPath.row]
|
||||
if let removeNode = expandedNodes.firstIndex(of: collapseNode) {
|
||||
expandedNodes.remove(at: removeNode)
|
||||
}
|
||||
|
||||
var indexPathsToRemove = [IndexPath]()
|
||||
|
||||
for child in collapseNode.childNodes {
|
||||
if let index = shadowTable[indexPath.section].firstIndex(of: child) {
|
||||
indexPathsToRemove.append(IndexPath(row: index, section: indexPath.section))
|
||||
}
|
||||
}
|
||||
|
||||
for child in collapseNode.childNodes {
|
||||
if let index = shadowTable[indexPath.section].firstIndex(of: child) {
|
||||
shadowTable[indexPath.section].remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
completion(indexPathsToRemove)
|
||||
|
||||
animatingChanges = false
|
||||
|
||||
}
|
||||
|
||||
func indexesForArticleIDs(_ articleIDs: Set<String>) -> IndexSet {
|
||||
|
||||
var indexes = IndexSet()
|
||||
|
||||
articleIDs.forEach { (articleID) in
|
||||
guard let oneIndex = row(for: articleID) else {
|
||||
return
|
||||
}
|
||||
if oneIndex != NSNotFound {
|
||||
indexes.insert(oneIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return indexes
|
||||
}
|
||||
|
||||
func selectNextUnread() {
|
||||
|
||||
// This should never happen, but I don't want to risk throwing us
|
||||
// into an infinate loop searching for an unread that isn't there.
|
||||
if appDelegate.unreadCount < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
if selectNextUnreadArticleInTimeline() {
|
||||
return
|
||||
}
|
||||
|
||||
selectNextUnreadFeedFetcher()
|
||||
selectNextUnreadArticleInTimeline()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NavigationStateController {
|
||||
|
||||
func rebuildBackingStores() {
|
||||
if !animatingChanges && !BatchUpdate.shared.isPerforming {
|
||||
treeController.rebuild()
|
||||
rebuildShadowTable()
|
||||
NotificationCenter.default.post(name: .BackingStoresDidRebuild, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func updateShowAvatars() {
|
||||
|
||||
if showFeedNames {
|
||||
self.showAvatars = true
|
||||
return
|
||||
}
|
||||
|
||||
for article in articles {
|
||||
if let authors = article.authors {
|
||||
for author in authors {
|
||||
if author.avatarURL != nil {
|
||||
self.showAvatars = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.showAvatars = false
|
||||
}
|
||||
|
||||
// MARK: Select Next Unread
|
||||
|
||||
@discardableResult
|
||||
func selectNextUnreadArticleInTimeline() -> Bool {
|
||||
|
||||
let startingRow: Int = {
|
||||
if let indexPath = currentArticleIndexPath {
|
||||
return indexPath.row
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}()
|
||||
|
||||
for i in startingRow..<articles.count {
|
||||
let article = articles[i]
|
||||
if !article.status.read {
|
||||
currentArticleIndexPath = IndexPath(row: i, section: 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
func selectNextUnreadFeedFetcher() {
|
||||
|
||||
guard let indexPath = currentMasterIndexPath else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
// Increment or wrap around the IndexPath
|
||||
let nextIndexPath: IndexPath = {
|
||||
if indexPath.row + 1 >= shadowTable[indexPath.section].count {
|
||||
if indexPath.section + 1 >= shadowTable.count {
|
||||
return IndexPath(row: 0, section: 0)
|
||||
} else {
|
||||
return IndexPath(row: 0, section: indexPath.section + 1)
|
||||
}
|
||||
} else {
|
||||
return IndexPath(row: indexPath.row + 1, section: indexPath.section)
|
||||
}
|
||||
}()
|
||||
|
||||
if selectNextUnreadFeedFetcher(startingWith: nextIndexPath) {
|
||||
return
|
||||
}
|
||||
selectNextUnreadFeedFetcher(startingWith: IndexPath(row: 0, section: 0))
|
||||
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectNextUnreadFeedFetcher(startingWith indexPath: IndexPath) -> Bool {
|
||||
|
||||
for i in indexPath.section..<shadowTable.count {
|
||||
|
||||
for j in indexPath.row..<shadowTable[indexPath.section].count {
|
||||
|
||||
let nextIndexPath = IndexPath(row: j, section: i)
|
||||
guard let node = nodeFor(nextIndexPath), let unreadCountProvider = node.representedObject as? UnreadCountProvider else {
|
||||
assertionFailure()
|
||||
return true
|
||||
}
|
||||
|
||||
if expandedNodes.contains(node) {
|
||||
continue
|
||||
}
|
||||
|
||||
if unreadCountProvider.unreadCount > 0 {
|
||||
currentMasterIndexPath = nextIndexPath
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
// MARK: Fetching Articles
|
||||
|
||||
func fetchArticles() {
|
||||
|
||||
guard let timelineFetcher = timelineFetcher else {
|
||||
articles = ArticleArray()
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedArticles = timelineFetcher.fetchArticles()
|
||||
updateArticles(with: fetchedArticles)
|
||||
|
||||
}
|
||||
|
||||
func emptyTheTimeline() {
|
||||
if !articles.isEmpty {
|
||||
articles = [Article]()
|
||||
}
|
||||
}
|
||||
|
||||
func sortDirectionDidChange() {
|
||||
updateArticles(with: Set(articles))
|
||||
}
|
||||
|
||||
func updateArticles(with unsortedArticles: Set<Article>) {
|
||||
let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection)
|
||||
if articles != sortedArticles {
|
||||
articles = sortedArticles
|
||||
}
|
||||
}
|
||||
|
||||
func row(for articleID: String) -> Int? {
|
||||
updateArticleRowMapIfNeeded()
|
||||
return articleRowMap[articleID]
|
||||
}
|
||||
|
||||
func updateArticleRowMap() {
|
||||
var rowMap = [String: Int]()
|
||||
var index = 0
|
||||
articles.forEach { (article) in
|
||||
rowMap[article.articleID] = index
|
||||
index += 1
|
||||
}
|
||||
articleRowMap = rowMap
|
||||
}
|
||||
|
||||
func updateArticleRowMapIfNeeded() {
|
||||
if articleRowMap.isEmpty {
|
||||
updateArticleRowMap()
|
||||
}
|
||||
}
|
||||
|
||||
func queueFetchAndMergeArticles() {
|
||||
NavigationStateController.fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticles))
|
||||
}
|
||||
|
||||
@objc func fetchAndMergeArticles() {
|
||||
|
||||
guard let timelineFetcher = timelineFetcher else {
|
||||
return
|
||||
}
|
||||
|
||||
var unsortedArticles = timelineFetcher.fetchArticles()
|
||||
|
||||
// Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles.
|
||||
let unsortedArticleIDs = unsortedArticles.articleIDs()
|
||||
for article in articles {
|
||||
if !unsortedArticleIDs.contains(article.articleID) {
|
||||
unsortedArticles.insert(article)
|
||||
}
|
||||
}
|
||||
|
||||
updateArticles(with: unsortedArticles)
|
||||
|
||||
}
|
||||
|
||||
func timelineFetcherContainsAnyPseudoFeed() -> Bool {
|
||||
if timelineFetcher is PseudoFeed {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func timelineFetcherContainsAnyFeed(_ feeds: Set<Feed>) -> Bool {
|
||||
|
||||
// Return true if there’s a match or if a folder contains (recursively) one of feeds
|
||||
|
||||
if let feed = timelineFetcher as? Feed {
|
||||
for oneFeed in feeds {
|
||||
if feed.feedID == oneFeed.feedID || feed.url == oneFeed.url {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else if let folder = timelineFetcher as? Folder {
|
||||
for oneFeed in feeds {
|
||||
if folder.hasFeed(with: oneFeed.feedID) || folder.hasFeed(withURL: oneFeed.url) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user