2020-06-30 11:03:33 -05:00
|
|
|
|
//
|
|
|
|
|
// TimelineModel.swift
|
|
|
|
|
// NetNewsWire
|
|
|
|
|
//
|
|
|
|
|
// Created by Maurice Parker on 6/30/20.
|
|
|
|
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
|
|
|
|
//
|
|
|
|
|
|
2020-07-17 20:08:49 -05:00
|
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
#else
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
2020-07-11 18:22:47 -05:00
|
|
|
|
import Combine
|
2020-06-30 11:03:33 -05:00
|
|
|
|
import RSCore
|
|
|
|
|
import Account
|
2020-07-01 12:30:55 -05:00
|
|
|
|
import Articles
|
2020-06-30 11:03:33 -05:00
|
|
|
|
|
|
|
|
|
protocol TimelineModelDelegate: class {
|
2020-07-18 04:35:44 -05:00
|
|
|
|
var selectedFeeds: Published<[Feed]>.Publisher { get }
|
2020-06-30 11:03:33 -05:00
|
|
|
|
func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-11 18:22:47 -05:00
|
|
|
|
class TimelineModel: ObservableObject, UndoableCommandRunner {
|
2020-06-30 11:03:33 -05:00
|
|
|
|
|
|
|
|
|
weak var delegate: TimelineModelDelegate?
|
|
|
|
|
|
2020-07-11 12:47:13 -05:00
|
|
|
|
@Published var nameForDisplay = ""
|
2020-07-12 14:43:52 -05:00
|
|
|
|
@Published var selectedArticleIDs = Set<String>() // Don't use directly. Use selectedArticles
|
|
|
|
|
@Published var selectedArticleID: String? = .none // Don't use directly. Use selectedArticles
|
2020-07-11 18:22:47 -05:00
|
|
|
|
@Published var selectedArticles = [Article]()
|
2020-07-12 16:48:39 -05:00
|
|
|
|
@Published var readFilterEnabledTable = [FeedIdentifier: Bool]()
|
|
|
|
|
@Published var isReadFiltered: Bool? = nil
|
2020-07-12 10:52:42 -05:00
|
|
|
|
|
2020-07-18 04:58:46 -05:00
|
|
|
|
@Published var articles = [Article]() {
|
|
|
|
|
didSet {
|
|
|
|
|
articleDictionaryNeedsUpdate = true
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-18 16:36:20 -05:00
|
|
|
|
|
|
|
|
|
@Published var timelineItems = [TimelineItem]() {
|
|
|
|
|
didSet {
|
|
|
|
|
timelineItemDictionaryNeedsUpdate = true
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-18 04:58:46 -05:00
|
|
|
|
|
2020-07-11 18:22:47 -05:00
|
|
|
|
var undoManager: UndoManager?
|
|
|
|
|
var undoableCommands = [UndoableCommand]()
|
|
|
|
|
|
2020-07-18 15:20:15 -05:00
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
2020-07-11 18:22:47 -05:00
|
|
|
|
|
2020-07-12 16:48:39 -05:00
|
|
|
|
private var feeds = [Feed]()
|
2020-07-01 12:30:55 -05:00
|
|
|
|
private var fetchSerialNumber = 0
|
|
|
|
|
private let fetchRequestQueue = FetchRequestQueue()
|
|
|
|
|
private var exceptionArticleFetcher: ArticleFetcher?
|
2020-07-18 23:09:39 -05:00
|
|
|
|
|
|
|
|
|
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0)
|
|
|
|
|
|
2020-07-06 21:14:05 -05:00
|
|
|
|
private var articleDictionaryNeedsUpdate = true
|
|
|
|
|
private var _idToArticleDictionary = [String: Article]()
|
2020-07-09 20:10:52 -05:00
|
|
|
|
private var idToArticleDictionary: [String: Article] {
|
2020-07-06 21:14:05 -05:00
|
|
|
|
if articleDictionaryNeedsUpdate {
|
|
|
|
|
rebuildArticleDictionaries()
|
|
|
|
|
}
|
|
|
|
|
return _idToArticleDictionary
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 16:36:20 -05:00
|
|
|
|
private var timelineItemDictionaryNeedsUpdate = true
|
|
|
|
|
private var _idToTimelineItemDictionary = [String: Int]()
|
|
|
|
|
private var idToTimelineItemDictionary: [String: Int] {
|
|
|
|
|
if timelineItemDictionaryNeedsUpdate {
|
|
|
|
|
rebuildTimelineItemDictionaries()
|
|
|
|
|
}
|
|
|
|
|
return _idToTimelineItemDictionary
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-13 16:06:42 -05:00
|
|
|
|
private var sortDirection = AppDefaults.shared.timelineSortDirection {
|
|
|
|
|
didSet {
|
|
|
|
|
if sortDirection != oldValue {
|
|
|
|
|
sortParametersDidChange()
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-01 12:30:55 -05:00
|
|
|
|
}
|
2020-07-18 04:35:44 -05:00
|
|
|
|
|
2020-07-13 16:06:42 -05:00
|
|
|
|
private var groupByFeed = AppDefaults.shared.timelineGroupByFeed {
|
|
|
|
|
didSet {
|
|
|
|
|
if groupByFeed != oldValue {
|
|
|
|
|
sortParametersDidChange()
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-01 12:30:55 -05:00
|
|
|
|
}
|
2020-07-01 11:13:11 -05:00
|
|
|
|
|
2020-07-18 04:35:44 -05:00
|
|
|
|
func startup() {
|
2020-07-18 16:53:30 -05:00
|
|
|
|
subscribeToArticleStatusChanges()
|
|
|
|
|
subscribeToUserDefaultsChanges()
|
|
|
|
|
subscribeToSelectedFeedChanges()
|
|
|
|
|
subscribeToSelectedArticleSelectionChanges()
|
2020-07-18 23:13:57 -05:00
|
|
|
|
subscribeToAccountDidDownloadArticles()
|
2020-07-18 16:53:30 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Subscriptions
|
|
|
|
|
|
|
|
|
|
func subscribeToArticleStatusChanges() {
|
2020-07-18 23:09:39 -05:00
|
|
|
|
NotificationCenter.default.publisher(for: .StatusesDidChange).sink { [weak self] note in
|
2020-07-18 15:20:15 -05:00
|
|
|
|
guard let self = self, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
|
|
|
|
|
return
|
|
|
|
|
}
|
2020-07-18 16:36:20 -05:00
|
|
|
|
articleIDs.forEach { articleID in
|
|
|
|
|
if let timelineItemIndex = self.idToTimelineItemDictionary[articleID] {
|
|
|
|
|
self.timelineItems[timelineItemIndex].updateStatus()
|
2020-07-18 15:20:15 -05:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.store(in: &cancellables)
|
2020-07-18 16:53:30 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 23:09:39 -05:00
|
|
|
|
func subscribeToAccountDidDownloadArticles() {
|
|
|
|
|
NotificationCenter.default.publisher(for: .AccountDidDownloadArticles).sink { [weak self] note in
|
|
|
|
|
guard let self = self, let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set<WebFeed> else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if self.anySelectedFeedIntersection(with: feeds) || self.anySelectedFeedIsPseudoFeed() {
|
|
|
|
|
self.queueFetchAndMergeArticles()
|
|
|
|
|
}
|
|
|
|
|
}.store(in: &cancellables)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 16:53:30 -05:00
|
|
|
|
func subscribeToUserDefaultsChanges() {
|
2020-07-18 23:09:39 -05:00
|
|
|
|
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { [weak self] _ in
|
2020-07-18 15:20:15 -05:00
|
|
|
|
self?.sortDirection = AppDefaults.shared.timelineSortDirection
|
|
|
|
|
self?.groupByFeed = AppDefaults.shared.timelineGroupByFeed
|
|
|
|
|
}.store(in: &cancellables)
|
2020-07-18 16:53:30 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func subscribeToSelectedFeedChanges() {
|
2020-07-18 15:20:15 -05:00
|
|
|
|
delegate?.selectedFeeds.sink { [weak self] feeds in
|
2020-07-18 04:35:44 -05:00
|
|
|
|
guard let self = self else { return }
|
2020-07-18 16:12:10 -05:00
|
|
|
|
self.feeds = feeds
|
|
|
|
|
self.fetchArticles()
|
2020-07-18 15:20:15 -05:00
|
|
|
|
}.store(in: &cancellables)
|
2020-07-18 16:53:30 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func subscribeToSelectedArticleSelectionChanges() {
|
2020-07-18 19:56:30 -05:00
|
|
|
|
$selectedArticleIDs.map { [weak self] articleIDs in
|
|
|
|
|
return articleIDs.compactMap { self?.idToArticleDictionary[$0] }
|
|
|
|
|
}
|
|
|
|
|
.assign(to: $selectedArticles)
|
2020-07-11 18:22:47 -05:00
|
|
|
|
|
2020-07-18 21:02:38 -05:00
|
|
|
|
$selectedArticleID.compactMap { [weak self] articleID in
|
2020-07-18 19:56:30 -05:00
|
|
|
|
if let articleID = articleID, let article = self?.idToArticleDictionary[articleID] {
|
|
|
|
|
return [article]
|
|
|
|
|
} else {
|
2020-07-18 21:02:38 -05:00
|
|
|
|
return nil
|
2020-07-11 18:22:47 -05:00
|
|
|
|
}
|
2020-07-18 19:56:30 -05:00
|
|
|
|
}
|
|
|
|
|
.assign(to: $selectedArticles)
|
2020-07-11 19:52:28 -05:00
|
|
|
|
|
2020-07-18 19:56:30 -05:00
|
|
|
|
$selectedArticles
|
|
|
|
|
.filter { $0.count == 1 }
|
|
|
|
|
.compactMap { $0.first }
|
|
|
|
|
.filter { !$0.status.read }
|
|
|
|
|
.sink { markArticles(Set([$0]), statusKey: .read, flag: true) }
|
|
|
|
|
.store(in: &cancellables)
|
2020-06-30 11:03:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: API
|
|
|
|
|
|
2020-07-12 16:48:39 -05:00
|
|
|
|
func toggleReadFilter() {
|
|
|
|
|
guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return }
|
|
|
|
|
readFilterEnabledTable[feedID] = !filter
|
|
|
|
|
isReadFiltered = !filter
|
2020-07-18 16:12:10 -05:00
|
|
|
|
self.fetchArticles()
|
2020-07-12 16:48:39 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-11 19:52:28 -05:00
|
|
|
|
func toggleReadStatusForSelectedArticles() {
|
|
|
|
|
guard !selectedArticles.isEmpty else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if selectedArticles.anyArticleIsUnread() {
|
|
|
|
|
markSelectedArticlesAsRead()
|
|
|
|
|
} else {
|
|
|
|
|
markSelectedArticlesAsUnread()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func canMarkIndicatedArticlesAsRead(_ article: Article) -> Bool {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
return articles.anyArticleIsUnread()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markIndicatedArticlesAsRead(_ article: Article) {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
markArticlesWithUndo(articles, statusKey: .read, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-11 19:52:28 -05:00
|
|
|
|
func markSelectedArticlesAsRead() {
|
2020-07-17 19:46:05 -05:00
|
|
|
|
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canMarkIndicatedArticlesAsUnread(_ article: Article) -> Bool {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
return articles.anyArticleIsReadAndCanMarkUnread()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markIndicatedArticlesAsUnread(_ article: Article) {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
markArticlesWithUndo(articles, statusKey: .read, flag: false)
|
2020-07-11 19:52:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markSelectedArticlesAsUnread() {
|
2020-07-17 19:46:05 -05:00
|
|
|
|
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canMarkAboveAsRead(_ article: Article) -> Bool {
|
|
|
|
|
let article = indicatedAboveArticle(article)
|
|
|
|
|
return articles.articlesAbove(article: article).canMarkAllAsRead()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markAboveAsRead(_ article: Article) {
|
|
|
|
|
let article = indicatedAboveArticle(article)
|
|
|
|
|
let articlesToMark = articles.articlesAbove(article: article)
|
|
|
|
|
guard !articlesToMark.isEmpty else { return }
|
|
|
|
|
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canMarkBelowAsRead(_ article: Article) -> Bool {
|
|
|
|
|
let article = indicatedBelowArticle(article)
|
|
|
|
|
return articles.articlesBelow(article: article).canMarkAllAsRead()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markBelowAsRead(_ article: Article) {
|
|
|
|
|
let article = indicatedBelowArticle(article)
|
|
|
|
|
let articlesToMark = articles.articlesBelow(article: article)
|
|
|
|
|
guard !articlesToMark.isEmpty else { return }
|
|
|
|
|
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
|
2020-07-11 19:52:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 20:29:53 -05:00
|
|
|
|
func canMarkAllAsReadInFeed(_ feed: Feed) -> Bool {
|
|
|
|
|
guard let articlesSet = try? feed.fetchArticles() else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return Array(articlesSet).canMarkAllAsRead()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markAllAsReadInFeed(_ feed: Feed) {
|
|
|
|
|
guard let articlesSet = try? feed.fetchArticles() else { return }
|
|
|
|
|
let articlesToMark = Array(articlesSet)
|
|
|
|
|
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 04:58:46 -05:00
|
|
|
|
func canMarkAllAsRead() -> Bool {
|
|
|
|
|
return articles.canMarkAllAsRead()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markAllAsRead() {
|
|
|
|
|
markArticlesWithUndo(articles, statusKey: .read, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-11 19:52:28 -05:00
|
|
|
|
func toggleStarredStatusForSelectedArticles() {
|
|
|
|
|
guard !selectedArticles.isEmpty else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if selectedArticles.anyArticleIsUnstarred() {
|
|
|
|
|
markSelectedArticlesAsStarred()
|
|
|
|
|
} else {
|
|
|
|
|
markSelectedArticlesAsUnstarred()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func canMarkIndicatedArticlesAsStarred(_ article: Article) -> Bool {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
return articles.anyArticleIsUnstarred()
|
2020-07-11 19:52:28 -05:00
|
|
|
|
}
|
2020-07-17 17:59:35 -05:00
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func markIndicatedArticlesAsStarred(_ article: Article) {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
markArticlesWithUndo(articles, statusKey: .starred, flag: true)
|
2020-07-17 17:59:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func markSelectedArticlesAsStarred() {
|
|
|
|
|
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canMarkIndicatedArticlesAsUnstarred(_ article: Article) -> Bool {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
return articles.anyArticleIsStarred()
|
2020-07-17 17:59:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func markIndicatedArticlesAsUnstarred(_ article: Article) {
|
|
|
|
|
let articles = indicatedArticles(article)
|
|
|
|
|
markArticlesWithUndo(articles, statusKey: .starred, flag: false)
|
2020-07-17 17:59:35 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func markSelectedArticlesAsUnstarred() {
|
|
|
|
|
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: false)
|
2020-07-17 17:59:35 -05:00
|
|
|
|
}
|
2020-07-17 20:08:49 -05:00
|
|
|
|
|
|
|
|
|
func canOpenIndicatedArticleInBrowser(_ article: Article) -> Bool {
|
|
|
|
|
guard indicatedArticles(article).count == 1 else { return false }
|
|
|
|
|
return article.preferredLink != nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openIndicatedArticleInBrowser(_ article: Article) {
|
|
|
|
|
guard let link = article.preferredLink else { return }
|
|
|
|
|
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
|
|
|
|
|
#else
|
|
|
|
|
guard let url = URL(string: link) else { return }
|
|
|
|
|
UIApplication.shared.open(url, options: [:])
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func openSelectedArticleInBrowser() {
|
|
|
|
|
guard let article = selectedArticles.first else { return }
|
|
|
|
|
openIndicatedArticleInBrowser(article)
|
|
|
|
|
}
|
2020-07-17 19:46:05 -05:00
|
|
|
|
|
2020-07-19 13:23:08 -05:00
|
|
|
|
func canGoToNextUnread() -> Bool {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func goToNextUnread() {
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-06 21:14:05 -05:00
|
|
|
|
func articleFor(_ articleID: String) -> Article? {
|
2020-07-09 20:10:52 -05:00
|
|
|
|
return idToArticleDictionary[articleID]
|
2020-07-06 21:14:05 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findPrevArticle(_ article: Article) -> Article? {
|
|
|
|
|
guard let index = articles.firstIndex(of: article), index > 0 else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return articles[index - 1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findNextArticle(_ article: Article) -> Article? {
|
|
|
|
|
guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return articles[index + 1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectArticle(_ article: Article) {
|
|
|
|
|
// TODO: Implement me!
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-30 11:03:33 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Private
|
2020-07-01 12:30:55 -05:00
|
|
|
|
|
2020-06-30 11:03:33 -05:00
|
|
|
|
private extension TimelineModel {
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func indicatedArticles(_ article: Article) -> [Article] {
|
|
|
|
|
if selectedArticles.contains(article) {
|
|
|
|
|
return selectedArticles
|
|
|
|
|
} else {
|
|
|
|
|
return [article]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func indicatedAboveArticle(_ article: Article) -> Article {
|
2020-07-17 18:15:42 -05:00
|
|
|
|
if selectedArticles.contains(article) {
|
|
|
|
|
return selectedArticles.first!
|
|
|
|
|
} else {
|
|
|
|
|
return article
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func indicatedBelowArticle(_ article: Article) -> Article {
|
2020-07-17 18:15:42 -05:00
|
|
|
|
if selectedArticles.contains(article) {
|
|
|
|
|
return selectedArticles.last!
|
|
|
|
|
} else {
|
|
|
|
|
return article
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-17 19:46:05 -05:00
|
|
|
|
func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) {
|
|
|
|
|
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
runCommand(markReadCommand)
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-11 19:52:28 -05:00
|
|
|
|
// MARK: Timeline Management
|
2020-07-09 20:10:52 -05:00
|
|
|
|
|
2020-07-12 16:48:39 -05:00
|
|
|
|
func resetReadFilter() {
|
|
|
|
|
guard feeds.count == 1, let timelineFeed = feeds.first else {
|
|
|
|
|
isReadFiltered = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard timelineFeed.defaultReadFilterType != .alwaysRead else {
|
|
|
|
|
isReadFiltered = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] {
|
|
|
|
|
isReadFiltered = readFilterEnabled
|
|
|
|
|
} else {
|
|
|
|
|
isReadFiltered = timelineFeed.defaultReadFilterType == .read
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-13 16:06:42 -05:00
|
|
|
|
func sortParametersDidChange() {
|
|
|
|
|
performBlockAndRestoreSelection {
|
|
|
|
|
articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
|
|
|
|
|
rebuildTimelineItems()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-01 12:30:55 -05:00
|
|
|
|
func performBlockAndRestoreSelection(_ block: (() -> Void)) {
|
2020-07-19 11:38:15 -05:00
|
|
|
|
let savedArticleIDs = selectedArticleIDs
|
|
|
|
|
let savedArticleID = selectedArticleID
|
2020-07-01 12:30:55 -05:00
|
|
|
|
block()
|
2020-07-19 11:38:15 -05:00
|
|
|
|
selectedArticleIDs = savedArticleIDs
|
|
|
|
|
selectedArticleID = savedArticleID
|
2020-07-01 12:30:55 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-06 21:14:05 -05:00
|
|
|
|
func rebuildArticleDictionaries() {
|
|
|
|
|
var idDictionary = [String: Article]()
|
|
|
|
|
articles.forEach { article in
|
|
|
|
|
idDictionary[article.articleID] = article
|
|
|
|
|
}
|
|
|
|
|
_idToArticleDictionary = idDictionary
|
|
|
|
|
articleDictionaryNeedsUpdate = false
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 16:36:20 -05:00
|
|
|
|
func rebuildTimelineItemDictionaries() {
|
|
|
|
|
var idDictionary = [String: Int]()
|
|
|
|
|
for (index, timelineItem) in timelineItems.enumerated() {
|
|
|
|
|
idDictionary[timelineItem.article.articleID] = index
|
|
|
|
|
}
|
|
|
|
|
_idToTimelineItemDictionary = idDictionary
|
|
|
|
|
timelineItemDictionaryNeedsUpdate = false
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-01 12:30:55 -05:00
|
|
|
|
// MARK: Article Fetching
|
|
|
|
|
|
2020-07-18 16:12:10 -05:00
|
|
|
|
func fetchArticles() {
|
2020-07-18 21:16:55 -05:00
|
|
|
|
guard !feeds.isEmpty else {
|
|
|
|
|
nameForDisplay = ""
|
|
|
|
|
replaceArticles(with: Set<Article>())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 04:35:44 -05:00
|
|
|
|
if feeds.count == 1 {
|
|
|
|
|
nameForDisplay = feeds.first!.nameForDisplay
|
|
|
|
|
} else {
|
|
|
|
|
nameForDisplay = NSLocalizedString("Multiple", comment: "Multiple Feeds")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetReadFilter()
|
|
|
|
|
fetchAndReplaceArticlesAsync()
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-12 17:30:35 -05:00
|
|
|
|
func fetchAndReplaceArticlesAsync() {
|
2020-07-01 12:30:55 -05:00
|
|
|
|
var fetchers = feeds as [ArticleFetcher]
|
|
|
|
|
if let fetcher = exceptionArticleFetcher {
|
|
|
|
|
fetchers.append(fetcher)
|
|
|
|
|
exceptionArticleFetcher = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchUnsortedArticlesAsync(for: fetchers) { [weak self] (articles) in
|
|
|
|
|
self?.replaceArticles(with: articles)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cancelPendingAsyncFetches() {
|
|
|
|
|
fetchSerialNumber += 1
|
|
|
|
|
fetchRequestQueue.cancelAllRequests()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) {
|
|
|
|
|
// The callback will *not* be called if the fetch is no longer relevant — that is,
|
|
|
|
|
// if it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t get called.
|
|
|
|
|
precondition(Thread.isMainThread)
|
|
|
|
|
cancelPendingAsyncFetches()
|
2020-07-12 16:48:39 -05:00
|
|
|
|
|
2020-07-18 16:12:10 -05:00
|
|
|
|
let filtered = isReadFiltered ?? false
|
|
|
|
|
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: filtered, representedObjects: representedObjects) { [weak self] (articles, operation) in
|
2020-07-01 12:30:55 -05:00
|
|
|
|
precondition(Thread.isMainThread)
|
|
|
|
|
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
completion(articles)
|
|
|
|
|
}
|
|
|
|
|
fetchRequestQueue.add(fetchOperation)
|
|
|
|
|
}
|
2020-06-30 11:03:33 -05:00
|
|
|
|
|
2020-07-01 12:30:55 -05:00
|
|
|
|
func replaceArticles(with unsortedArticles: Set<Article>) {
|
2020-07-02 05:46:56 -05:00
|
|
|
|
articles = Array(unsortedArticles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
|
2020-07-12 17:30:35 -05:00
|
|
|
|
rebuildTimelineItems()
|
2020-07-16 12:27:40 -05:00
|
|
|
|
|
|
|
|
|
selectedArticleIDs = Set<String>()
|
|
|
|
|
selectedArticleID = nil
|
2020-07-01 12:30:55 -05:00
|
|
|
|
// TODO: Update unread counts and other item done in didSet on AppKit
|
|
|
|
|
}
|
2020-07-02 17:36:50 -05:00
|
|
|
|
|
2020-07-12 17:30:35 -05:00
|
|
|
|
func rebuildTimelineItems() {
|
2020-07-18 16:12:10 -05:00
|
|
|
|
timelineItems = articles.map { TimelineItem(article: $0) }
|
2020-07-12 14:43:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-18 23:09:39 -05:00
|
|
|
|
func queueFetchAndMergeArticles() {
|
|
|
|
|
TimelineModel.fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticles))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc func fetchAndMergeArticles() {
|
|
|
|
|
|
|
|
|
|
fetchUnsortedArticlesAsync(for: feeds) { [weak self] (unsortedArticles) in
|
|
|
|
|
// Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles.
|
|
|
|
|
guard let strongSelf = self else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let unsortedArticleIDs = unsortedArticles.articleIDs()
|
|
|
|
|
var updatedArticles = unsortedArticles
|
|
|
|
|
for article in strongSelf.articles {
|
|
|
|
|
if !unsortedArticleIDs.contains(article.articleID) {
|
|
|
|
|
updatedArticles.insert(article)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
strongSelf.performBlockAndRestoreSelection {
|
|
|
|
|
strongSelf.replaceArticles(with: updatedArticles)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func anySelectedFeedIsPseudoFeed() -> Bool {
|
|
|
|
|
return feeds.contains(where: { $0 is PseudoFeed})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func anySelectedFeedIntersection(with webFeeds: Set<WebFeed>) -> Bool {
|
|
|
|
|
for feed in feeds {
|
|
|
|
|
if let selectedWebFeed = feed as? WebFeed {
|
|
|
|
|
for webFeed in webFeeds {
|
|
|
|
|
if selectedWebFeed.webFeedID == webFeed.webFeedID || selectedWebFeed.url == webFeed.url {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if let folder = feed as? Folder {
|
|
|
|
|
for webFeed in webFeeds {
|
|
|
|
|
if folder.hasWebFeed(with: webFeed.webFeedID) || folder.hasWebFeed(withURL: webFeed.url) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
2020-06-30 11:03:33 -05:00
|
|
|
|
}
|