Fix lint issues.
This commit is contained in:
parent
bbef99f2d3
commit
10f4351904
@ -16,11 +16,11 @@ import UIKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension AccountType {
|
extension AccountType {
|
||||||
|
|
||||||
// TODO: Move this to the Account Package.
|
// TODO: Move this to the Account Package.
|
||||||
|
|
||||||
func localizedAccountName() -> String {
|
func localizedAccountName() -> String {
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .onMyMac:
|
case .onMyMac:
|
||||||
let defaultName: String
|
let defaultName: String
|
||||||
@ -52,7 +52,7 @@ extension AccountType {
|
|||||||
return NSLocalizedString("The Old Reader", comment: "Account name")
|
return NSLocalizedString("The Old Reader", comment: "Account name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SwiftUI Images
|
// MARK: - SwiftUI Images
|
||||||
func image() -> Image {
|
func image() -> Image {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -16,7 +16,7 @@ import Intents
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
class ActivityManager {
|
class ActivityManager {
|
||||||
|
|
||||||
private var nextUnreadActivity: NSUserActivity?
|
private var nextUnreadActivity: NSUserActivity?
|
||||||
private var selectingActivity: NSUserActivity?
|
private var selectingActivity: NSUserActivity?
|
||||||
private var readingActivity: NSUserActivity?
|
private var readingActivity: NSUserActivity?
|
||||||
@ -26,11 +26,11 @@ class ActivityManager {
|
|||||||
if let activity = readingActivity {
|
if let activity = readingActivity {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
if let activity = selectingActivity {
|
if let activity = selectingActivity {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
let activity = NSUserActivity(activityType: ActivityType.restoration.rawValue)
|
let activity = NSUserActivity(activityType: ActivityType.restoration.rawValue)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
activity.persistentIdentifier = UUID().uuidString
|
activity.persistentIdentifier = UUID().uuidString
|
||||||
@ -38,40 +38,40 @@ class ActivityManager {
|
|||||||
activity.becomeCurrent()
|
activity.becomeCurrent()
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateCurrentActivities() {
|
func invalidateCurrentActivities() {
|
||||||
invalidateReading()
|
invalidateReading()
|
||||||
invalidateSelecting()
|
invalidateSelecting()
|
||||||
invalidateNextUnread()
|
invalidateNextUnread()
|
||||||
}
|
}
|
||||||
|
|
||||||
func selecting(feed: SidebarItem) {
|
func selecting(feed: SidebarItem) {
|
||||||
invalidateCurrentActivities()
|
invalidateCurrentActivities()
|
||||||
|
|
||||||
selectingActivity = makeSelectFeedActivity(feed: feed)
|
selectingActivity = makeSelectFeedActivity(feed: feed)
|
||||||
|
|
||||||
if let feed = feed as? Feed {
|
if let feed = feed as? Feed {
|
||||||
updateSelectingActivityFeedSearchAttributes(with: feed)
|
updateSelectingActivityFeedSearchAttributes(with: feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
donate(selectingActivity!)
|
donate(selectingActivity!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateSelecting() {
|
func invalidateSelecting() {
|
||||||
selectingActivity?.invalidate()
|
selectingActivity?.invalidate()
|
||||||
selectingActivity = nil
|
selectingActivity = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectingNextUnread() {
|
func selectingNextUnread() {
|
||||||
guard nextUnreadActivity == nil else { return }
|
guard nextUnreadActivity == nil else { return }
|
||||||
|
|
||||||
nextUnreadActivity = NSUserActivity(activityType: ActivityType.nextUnread.rawValue)
|
nextUnreadActivity = NSUserActivity(activityType: ActivityType.nextUnread.rawValue)
|
||||||
nextUnreadActivity!.title = NSLocalizedString("See first unread article", comment: "First Unread")
|
nextUnreadActivity!.title = NSLocalizedString("See first unread article", comment: "First Unread")
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
nextUnreadActivity!.suggestedInvocationPhrase = nextUnreadActivity!.title
|
nextUnreadActivity!.suggestedInvocationPhrase = nextUnreadActivity!.title
|
||||||
nextUnreadActivity!.isEligibleForPrediction = true
|
nextUnreadActivity!.isEligibleForPrediction = true
|
||||||
@ -81,60 +81,60 @@ class ActivityManager {
|
|||||||
|
|
||||||
donate(nextUnreadActivity!)
|
donate(nextUnreadActivity!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateNextUnread() {
|
func invalidateNextUnread() {
|
||||||
nextUnreadActivity?.invalidate()
|
nextUnreadActivity?.invalidate()
|
||||||
nextUnreadActivity = nil
|
nextUnreadActivity = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reading(feed: SidebarItem?, article: Article?) {
|
func reading(feed: SidebarItem?, article: Article?) {
|
||||||
invalidateReading()
|
invalidateReading()
|
||||||
invalidateNextUnread()
|
invalidateNextUnread()
|
||||||
|
|
||||||
guard let article = article else { return }
|
guard let article = article else { return }
|
||||||
readingActivity = makeReadArticleActivity(feed: feed, article: article)
|
readingActivity = makeReadArticleActivity(feed: feed, article: article)
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
updateReadArticleSearchAttributes(with: article)
|
updateReadArticleSearchAttributes(with: article)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
donate(readingActivity!)
|
donate(readingActivity!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateReading() {
|
func invalidateReading() {
|
||||||
readingActivity?.invalidate()
|
readingActivity?.invalidate()
|
||||||
readingActivity = nil
|
readingActivity = nil
|
||||||
readingArticle = nil
|
readingArticle = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func cleanUp(_ account: Account) {
|
static func cleanUp(_ account: Account) {
|
||||||
var ids = [String]()
|
var ids = [String]()
|
||||||
|
|
||||||
if let folders = account.folders {
|
if let folders = account.folders {
|
||||||
for folder in folders {
|
for folder in folders {
|
||||||
ids.append(identifier(for: folder))
|
ids.append(identifier(for: folder))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for feed in account.flattenedFeeds() {
|
for feed in account.flattenedFeeds() {
|
||||||
ids.append(contentsOf: identifiers(for: feed))
|
ids.append(contentsOf: identifiers(for: feed))
|
||||||
}
|
}
|
||||||
|
|
||||||
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids)
|
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func cleanUp(_ folder: Folder) {
|
static func cleanUp(_ folder: Folder) {
|
||||||
var ids = [String]()
|
var ids = [String]()
|
||||||
ids.append(identifier(for: folder))
|
ids.append(identifier(for: folder))
|
||||||
|
|
||||||
for feed in folder.flattenedFeeds() {
|
for feed in folder.flattenedFeeds() {
|
||||||
ids.append(contentsOf: identifiers(for: feed))
|
ids.append(contentsOf: identifiers(for: feed))
|
||||||
}
|
}
|
||||||
|
|
||||||
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids)
|
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func cleanUp(_ feed: Feed) {
|
static func cleanUp(_ feed: Feed) {
|
||||||
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: identifiers(for: feed))
|
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: identifiers(for: feed))
|
||||||
}
|
}
|
||||||
@ -144,13 +144,13 @@ class ActivityManager {
|
|||||||
guard let feed = note.userInfo?[UserInfoKey.feed] as? Feed, let activityFeedId = selectingActivity?.userInfo?[ArticlePathKey.feedID] as? String else {
|
guard let feed = note.userInfo?[UserInfoKey.feed] as? Feed, let activityFeedId = selectingActivity?.userInfo?[ArticlePathKey.feedID] as? String else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let article = readingArticle, activityFeedId == article.feedID {
|
if let article = readingArticle, activityFeedId == article.feedID {
|
||||||
updateReadArticleSearchAttributes(with: article)
|
updateReadArticleSearchAttributes(with: article)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if activityFeedId == feed.feedID {
|
if activityFeedId == feed.feedID {
|
||||||
updateSelectingActivityFeedSearchAttributes(with: feed)
|
updateSelectingActivityFeedSearchAttributes(with: feed)
|
||||||
}
|
}
|
||||||
@ -161,17 +161,17 @@ class ActivityManager {
|
|||||||
// MARK: Private
|
// MARK: Private
|
||||||
|
|
||||||
private extension ActivityManager {
|
private extension ActivityManager {
|
||||||
|
|
||||||
func makeSelectFeedActivity(feed: SidebarItem) -> NSUserActivity {
|
func makeSelectFeedActivity(feed: SidebarItem) -> NSUserActivity {
|
||||||
let activity = NSUserActivity(activityType: ActivityType.selectFeed.rawValue)
|
let activity = NSUserActivity(activityType: ActivityType.selectFeed.rawValue)
|
||||||
|
|
||||||
let localizedText = NSLocalizedString("See articles in “%@”", comment: "See articles in Folder")
|
let localizedText = NSLocalizedString("See articles in “%@”", comment: "See articles in Folder")
|
||||||
let title = NSString.localizedStringWithFormat(localizedText as NSString, feed.nameForDisplay) as String
|
let title = NSString.localizedStringWithFormat(localizedText as NSString, feed.nameForDisplay) as String
|
||||||
activity.title = title
|
activity.title = title
|
||||||
|
|
||||||
activity.keywords = Set(makeKeywords(title))
|
activity.keywords = Set(makeKeywords(title))
|
||||||
activity.isEligibleForSearch = true
|
activity.isEligibleForSearch = true
|
||||||
|
|
||||||
let articleFetcherIdentifierUserInfo = feed.sidebarItemID?.userInfo ?? [AnyHashable: Any]()
|
let articleFetcherIdentifierUserInfo = feed.sidebarItemID?.userInfo ?? [AnyHashable: Any]()
|
||||||
activity.userInfo = [UserInfoKey.feedIdentifier: articleFetcherIdentifierUserInfo]
|
activity.userInfo = [UserInfoKey.feedIdentifier: articleFetcherIdentifierUserInfo]
|
||||||
activity.requiredUserInfoKeys = Set(activity.userInfo!.keys.map { $0 as! String })
|
activity.requiredUserInfoKeys = Set(activity.userInfo!.keys.map { $0 as! String })
|
||||||
@ -186,11 +186,11 @@ private extension ActivityManager {
|
|||||||
|
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeReadArticleActivity(feed: SidebarItem?, article: Article) -> NSUserActivity {
|
func makeReadArticleActivity(feed: SidebarItem?, article: Article) -> NSUserActivity {
|
||||||
let activity = NSUserActivity(activityType: ActivityType.readArticle.rawValue)
|
let activity = NSUserActivity(activityType: ActivityType.readArticle.rawValue)
|
||||||
activity.title = ArticleStringFormatter.truncatedTitle(article)
|
activity.title = ArticleStringFormatter.truncatedTitle(article)
|
||||||
|
|
||||||
if let feed = feed {
|
if let feed = feed {
|
||||||
let articleFetcherIdentifierUserInfo = feed.sidebarItemID?.userInfo ?? [AnyHashable: Any]()
|
let articleFetcherIdentifierUserInfo = feed.sidebarItemID?.userInfo ?? [AnyHashable: Any]()
|
||||||
let articlePathUserInfo = article.pathUserInfo
|
let articlePathUserInfo = article.pathUserInfo
|
||||||
@ -199,9 +199,9 @@ private extension ActivityManager {
|
|||||||
activity.userInfo = [UserInfoKey.articlePath: article.pathUserInfo]
|
activity.userInfo = [UserInfoKey.articlePath: article.pathUserInfo]
|
||||||
}
|
}
|
||||||
activity.requiredUserInfoKeys = Set(activity.userInfo!.keys.map { $0 as! String })
|
activity.requiredUserInfoKeys = Set(activity.userInfo!.keys.map { $0 as! String })
|
||||||
|
|
||||||
activity.isEligibleForHandoff = true
|
activity.isEligibleForHandoff = true
|
||||||
|
|
||||||
activity.persistentIdentifier = ActivityManager.identifier(for: article)
|
activity.persistentIdentifier = ActivityManager.identifier(for: article)
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ -212,13 +212,13 @@ private extension ActivityManager {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
readingArticle = article
|
readingArticle = article
|
||||||
|
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func updateReadArticleSearchAttributes(with article: Article) {
|
func updateReadArticleSearchAttributes(with article: Article) {
|
||||||
|
|
||||||
let attributeSet = CSSearchableItemAttributeSet(itemContentType: UTType.compositeContent.identifier)
|
let attributeSet = CSSearchableItemAttributeSet(itemContentType: UTType.compositeContent.identifier)
|
||||||
attributeSet.title = ArticleStringFormatter.truncatedTitle(article)
|
attributeSet.title = ArticleStringFormatter.truncatedTitle(article)
|
||||||
attributeSet.contentDescription = article.summary
|
attributeSet.contentDescription = article.summary
|
||||||
@ -228,25 +228,25 @@ private extension ActivityManager {
|
|||||||
if let iconImage = article.iconImage() {
|
if let iconImage = article.iconImage() {
|
||||||
attributeSet.thumbnailData = iconImage.image.pngData()
|
attributeSet.thumbnailData = iconImage.image.pngData()
|
||||||
}
|
}
|
||||||
|
|
||||||
readingActivity?.contentAttributeSet = attributeSet
|
readingActivity?.contentAttributeSet = attributeSet
|
||||||
readingActivity?.needsSave = true
|
readingActivity?.needsSave = true
|
||||||
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func makeKeywords(_ article: Article) -> [String] {
|
func makeKeywords(_ article: Article) -> [String] {
|
||||||
let feedNameKeywords = makeKeywords(article.feed?.nameForDisplay)
|
let feedNameKeywords = makeKeywords(article.feed?.nameForDisplay)
|
||||||
let articleTitleKeywords = makeKeywords(ArticleStringFormatter.truncatedTitle(article))
|
let articleTitleKeywords = makeKeywords(ArticleStringFormatter.truncatedTitle(article))
|
||||||
return feedNameKeywords + articleTitleKeywords
|
return feedNameKeywords + articleTitleKeywords
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeKeywords(_ value: String?) -> [String] {
|
func makeKeywords(_ value: String?) -> [String] {
|
||||||
return value?.components(separatedBy: " ").filter { $0.count > 2 } ?? []
|
return value?.components(separatedBy: " ").filter { $0.count > 2 } ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSelectingActivityFeedSearchAttributes(with feed: Feed) {
|
func updateSelectingActivityFeedSearchAttributes(with feed: Feed) {
|
||||||
|
|
||||||
let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.compositeContent)
|
let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.compositeContent)
|
||||||
attributeSet.title = feed.nameForDisplay
|
attributeSet.title = feed.nameForDisplay
|
||||||
attributeSet.keywords = makeKeywords(feed.nameForDisplay)
|
attributeSet.keywords = makeKeywords(feed.nameForDisplay)
|
||||||
@ -258,9 +258,9 @@ private extension ActivityManager {
|
|||||||
|
|
||||||
selectingActivity!.contentAttributeSet = attributeSet
|
selectingActivity!.contentAttributeSet = attributeSet
|
||||||
selectingActivity!.needsSave = true
|
selectingActivity!.needsSave = true
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func donate(_ activity: NSUserActivity) {
|
func donate(_ activity: NSUserActivity) {
|
||||||
// You have to put the search item in the index or the activity won't index
|
// You have to put the search item in the index or the activity won't index
|
||||||
// itself because the relatedUniqueIdentifier on the activity attributeset is populated.
|
// itself because the relatedUniqueIdentifier on the activity attributeset is populated.
|
||||||
@ -270,22 +270,22 @@ private extension ActivityManager {
|
|||||||
let searchableItem = CSSearchableItem(uniqueIdentifier: identifier, domainIdentifier: nil, attributeSet: tempAttributeSet)
|
let searchableItem = CSSearchableItem(uniqueIdentifier: identifier, domainIdentifier: nil, attributeSet: tempAttributeSet)
|
||||||
CSSearchableIndex.default().indexSearchableItems([searchableItem])
|
CSSearchableIndex.default().indexSearchableItems([searchableItem])
|
||||||
}
|
}
|
||||||
|
|
||||||
activity.becomeCurrent()
|
activity.becomeCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func identifier(for folder: Folder) -> String {
|
static func identifier(for folder: Folder) -> String {
|
||||||
return "account_\(folder.account!.accountID)_folder_\(folder.nameForDisplay)"
|
return "account_\(folder.account!.accountID)_folder_\(folder.nameForDisplay)"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func identifier(for feed: Feed) -> String {
|
static func identifier(for feed: Feed) -> String {
|
||||||
return "account_\(feed.account!.accountID)_feed_\(feed.feedID)"
|
return "account_\(feed.account!.accountID)_feed_\(feed.feedID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func identifier(for article: Article) -> String {
|
static func identifier(for article: Article) -> String {
|
||||||
return "account_\(article.accountID)_feed_\(article.feedID)_article_\(article.articleID)"
|
return "account_\(article.accountID)_feed_\(article.feedID)_article_\(article.articleID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func identifiers(for feed: Feed) -> [String] {
|
static func identifiers(for feed: Feed) -> [String] {
|
||||||
var ids = [String]()
|
var ids = [String]()
|
||||||
ids.append(identifier(for: feed))
|
ids.append(identifier(for: feed))
|
||||||
|
@ -24,23 +24,23 @@ protocol ArticleExtractorDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ArticleExtractor {
|
class ArticleExtractor {
|
||||||
|
|
||||||
private var dataTask: URLSessionDataTask? = nil
|
private var dataTask: URLSessionDataTask?
|
||||||
|
|
||||||
var state: ArticleExtractorState!
|
var state: ArticleExtractorState!
|
||||||
var article: ExtractedArticle?
|
var article: ExtractedArticle?
|
||||||
var delegate: ArticleExtractorDelegate?
|
var delegate: ArticleExtractorDelegate?
|
||||||
var articleLink: String?
|
var articleLink: String?
|
||||||
|
|
||||||
private var url: URL!
|
private var url: URL!
|
||||||
|
|
||||||
public init?(_ articleLink: String) {
|
public init?(_ articleLink: String) {
|
||||||
self.articleLink = articleLink
|
self.articleLink = articleLink
|
||||||
|
|
||||||
let clientURL = "https://extract.feedbin.com/parser"
|
let clientURL = "https://extract.feedbin.com/parser"
|
||||||
let username = SecretKey.mercuryClientID
|
let username = SecretKey.mercuryClientID
|
||||||
let signature = articleLink.hmacUsingSHA1(key: SecretKey.mercuryClientSecret)
|
let signature = articleLink.hmacUsingSHA1(key: SecretKey.mercuryClientSecret)
|
||||||
|
|
||||||
if let base64URL = articleLink.data(using: .utf8)?.base64EncodedString() {
|
if let base64URL = articleLink.data(using: .utf8)?.base64EncodedString() {
|
||||||
let fullURL = "\(clientURL)/\(username)/\(signature)?base64_url=\(base64URL)"
|
let fullURL = "\(clientURL)/\(username)/\(signature)?base64_url=\(base64URL)"
|
||||||
if let url = URL(string: fullURL) {
|
if let url = URL(string: fullURL) {
|
||||||
@ -48,18 +48,18 @@ class ArticleExtractor {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func process() {
|
public func process() {
|
||||||
|
|
||||||
state = .processing
|
state = .processing
|
||||||
|
|
||||||
dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
||||||
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
self.state = .failedToParse
|
self.state = .failedToParse
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -67,7 +67,7 @@ class ArticleExtractor {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
self.state = .failedToParse
|
self.state = .failedToParse
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -75,12 +75,12 @@ class ArticleExtractor {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
self.article = try decoder.decode(ExtractedArticle.self, from: data)
|
self.article = try decoder.decode(ExtractedArticle.self, from: data)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if self.article?.content == nil {
|
if self.article?.content == nil {
|
||||||
self.state = .failedToParse
|
self.state = .failedToParse
|
||||||
@ -96,16 +96,16 @@ class ArticleExtractor {
|
|||||||
self.delegate?.articleExtractionDidFail(with: error)
|
self.delegate?.articleExtractionDidFail(with: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dataTask!.resume()
|
dataTask!.resume()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func cancel() {
|
public func cancel() {
|
||||||
state = .cancelled
|
state = .cancelled
|
||||||
dataTask?.cancel()
|
dataTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ struct ExtractedArticle: Codable, Equatable {
|
|||||||
let direction: String?
|
let direction: String?
|
||||||
let totalPages: Int?
|
let totalPages: Int?
|
||||||
let renderedPages: Int?
|
let renderedPages: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title = "title"
|
case title = "title"
|
||||||
case author = "author"
|
case author = "author"
|
||||||
@ -41,5 +41,5 @@ struct ExtractedArticle: Codable, Equatable {
|
|||||||
case totalPages = "total_pages"
|
case totalPages = "total_pages"
|
||||||
case renderedPages = "rendered_pages"
|
case renderedPages = "rendered_pages"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -17,12 +17,12 @@ import Account
|
|||||||
struct ArticleRenderer {
|
struct ArticleRenderer {
|
||||||
|
|
||||||
typealias Rendering = (style: String, html: String, title: String, baseURL: String)
|
typealias Rendering = (style: String, html: String, title: String, baseURL: String)
|
||||||
|
|
||||||
struct Page {
|
struct Page {
|
||||||
let url: URL
|
let url: URL
|
||||||
let baseURL: URL
|
let baseURL: URL
|
||||||
let html: String
|
let html: String
|
||||||
|
|
||||||
init(name: String) {
|
init(name: String) {
|
||||||
url = Bundle.main.url(forResource: name, withExtension: "html")!
|
url = Bundle.main.url(forResource: name, withExtension: "html")!
|
||||||
baseURL = url.deletingLastPathComponent()
|
baseURL = url.deletingLastPathComponent()
|
||||||
@ -31,17 +31,17 @@ struct ArticleRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static var imageIconScheme = "nnwImageIcon"
|
static var imageIconScheme = "nnwImageIcon"
|
||||||
|
|
||||||
static var blank = Page(name: "blank")
|
static var blank = Page(name: "blank")
|
||||||
static var page = Page(name: "page")
|
static var page = Page(name: "page")
|
||||||
|
|
||||||
private let article: Article?
|
private let article: Article?
|
||||||
private let extractedArticle: ExtractedArticle?
|
private let extractedArticle: ExtractedArticle?
|
||||||
private let articleTheme: ArticleTheme
|
private let articleTheme: ArticleTheme
|
||||||
private let title: String
|
private let title: String
|
||||||
private let body: String
|
private let body: String
|
||||||
private let baseURL: String?
|
private let baseURL: String?
|
||||||
|
|
||||||
private static let longDateTimeFormatter: DateFormatter = {
|
private static let longDateTimeFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .long
|
formatter.dateStyle = .long
|
||||||
@ -140,7 +140,7 @@ struct ArticleRenderer {
|
|||||||
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
|
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
|
||||||
return (renderer.articleCSS, renderer.noSelectionHTML, renderer.title, renderer.baseURL ?? "")
|
return (renderer.articleCSS, renderer.noSelectionHTML, renderer.title, renderer.baseURL ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func noContentHTML(theme: ArticleTheme) -> Rendering {
|
static func noContentHTML(theme: ArticleTheme) -> Rendering {
|
||||||
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
|
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
|
||||||
return (renderer.articleCSS, renderer.noContentHTML, renderer.title, renderer.baseURL ?? "")
|
return (renderer.articleCSS, renderer.noContentHTML, renderer.title, renderer.baseURL ?? "")
|
||||||
@ -173,7 +173,7 @@ private extension ArticleRenderer {
|
|||||||
private var noContentHTML: String {
|
private var noContentHTML: String {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private var articleCSS: String {
|
private var articleCSS: String {
|
||||||
return try! MacroProcessor.renderedText(withTemplate: styleString(), substitutions: styleSubstitutions())
|
return try! MacroProcessor.renderedText(withTemplate: styleString(), substitutions: styleSubstitutions())
|
||||||
}
|
}
|
||||||
@ -205,10 +205,10 @@ private extension ArticleRenderer {
|
|||||||
assertionFailure("Article should have been set before calling this function.")
|
assertionFailure("Article should have been set before calling this function.")
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
d["title"] = title
|
d["title"] = title
|
||||||
d["preferred_link"] = article.preferredLink ?? ""
|
d["preferred_link"] = article.preferredLink ?? ""
|
||||||
|
|
||||||
if let externalLink = article.externalLink, externalLink != article.preferredLink {
|
if let externalLink = article.externalLink, externalLink != article.preferredLink {
|
||||||
d["external_link_label"] = NSLocalizedString("Link:", comment: "Link")
|
d["external_link_label"] = NSLocalizedString("Link:", comment: "Link")
|
||||||
d["external_link_stripped"] = externalLink.strippingHTTPOrHTTPSScheme
|
d["external_link_stripped"] = externalLink.strippingHTTPOrHTTPSScheme
|
||||||
@ -218,23 +218,22 @@ private extension ArticleRenderer {
|
|||||||
d["external_link_stripped"] = ""
|
d["external_link_stripped"] = ""
|
||||||
d["external_link"] = ""
|
d["external_link"] = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
d["body"] = body
|
d["body"] = body
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
d["text_size_class"] = AppDefaults.shared.articleTextSize.cssClass
|
d["text_size_class"] = AppDefaults.shared.articleTextSize.cssClass
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var components = URLComponents()
|
var components = URLComponents()
|
||||||
components.scheme = Self.imageIconScheme
|
components.scheme = Self.imageIconScheme
|
||||||
components.path = article.articleID
|
components.path = article.articleID
|
||||||
if let imageIconURLString = components.string {
|
if let imageIconURLString = components.string {
|
||||||
d["avatar_src"] = imageIconURLString
|
d["avatar_src"] = imageIconURLString
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
d["avatar_src"] = ""
|
d["avatar_src"] = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.title.isEmpty {
|
if self.title.isEmpty {
|
||||||
d["dateline_style"] = "articleDatelineTitle"
|
d["dateline_style"] = "articleDatelineTitle"
|
||||||
} else {
|
} else {
|
||||||
@ -273,7 +272,7 @@ private extension ArticleRenderer {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var byline = ""
|
var byline = ""
|
||||||
var isFirstAuthor = true
|
var isFirstAuthor = true
|
||||||
|
|
||||||
@ -283,27 +282,22 @@ private extension ArticleRenderer {
|
|||||||
}
|
}
|
||||||
isFirstAuthor = false
|
isFirstAuthor = false
|
||||||
|
|
||||||
var authorEmailAddress: String? = nil
|
var authorEmailAddress: String?
|
||||||
if let emailAddress = author.emailAddress, !(emailAddress.contains("noreply@") || emailAddress.contains("no-reply@")) {
|
if let emailAddress = author.emailAddress, !(emailAddress.contains("noreply@") || emailAddress.contains("no-reply@")) {
|
||||||
authorEmailAddress = emailAddress
|
authorEmailAddress = emailAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
if let emailAddress = authorEmailAddress, emailAddress.contains(" ") {
|
if let emailAddress = authorEmailAddress, emailAddress.contains(" ") {
|
||||||
byline += emailAddress // probably name plus email address
|
byline += emailAddress // probably name plus email address
|
||||||
}
|
} else if let name = author.name, let url = author.url {
|
||||||
else if let name = author.name, let url = author.url {
|
|
||||||
byline += name.htmlByAddingLink(url)
|
byline += name.htmlByAddingLink(url)
|
||||||
}
|
} else if let name = author.name, let emailAddress = authorEmailAddress {
|
||||||
else if let name = author.name, let emailAddress = authorEmailAddress {
|
|
||||||
byline += "\(name) <\(emailAddress)>"
|
byline += "\(name) <\(emailAddress)>"
|
||||||
}
|
} else if let name = author.name {
|
||||||
else if let name = author.name {
|
|
||||||
byline += name
|
byline += name
|
||||||
}
|
} else if let emailAddress = authorEmailAddress {
|
||||||
else if let emailAddress = authorEmailAddress {
|
|
||||||
byline += "<\(emailAddress)>" // TODO: mailto link
|
byline += "<\(emailAddress)>" // TODO: mailto link
|
||||||
}
|
} else if let url = author.url {
|
||||||
else if let url = author.url {
|
|
||||||
byline += String.htmlWithLink(url)
|
byline += String.htmlWithLink(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -355,4 +349,3 @@ private extension Article {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,9 +14,9 @@ enum ArticleTextSize: Int, CaseIterable, Identifiable {
|
|||||||
case large = 3
|
case large = 3
|
||||||
case xlarge = 4
|
case xlarge = 4
|
||||||
case xxlarge = 5
|
case xxlarge = 5
|
||||||
|
|
||||||
var id: String { description() }
|
var id: String { description() }
|
||||||
|
|
||||||
var cssClass: String {
|
var cssClass: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .small:
|
case .small:
|
||||||
@ -31,7 +31,7 @@ enum ArticleTextSize: Int, CaseIterable, Identifiable {
|
|||||||
return "xxLargeText"
|
return "xxLargeText"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func description() -> String {
|
func description() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .small:
|
case .small:
|
||||||
@ -46,5 +46,5 @@ enum ArticleTextSize: Int, CaseIterable, Identifiable {
|
|||||||
return NSLocalizedString("Extra Extra Large", comment: "XX-Large")
|
return NSLocalizedString("Extra Extra Large", comment: "XX-Large")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -9,58 +9,58 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ArticleTheme: Equatable {
|
struct ArticleTheme: Equatable {
|
||||||
|
|
||||||
static let defaultTheme = ArticleTheme()
|
static let defaultTheme = ArticleTheme()
|
||||||
static let nnwThemeSuffix = ".nnwtheme"
|
static let nnwThemeSuffix = ".nnwtheme"
|
||||||
|
|
||||||
private static let defaultThemeName = NSLocalizedString("Default", comment: "Default")
|
private static let defaultThemeName = NSLocalizedString("Default", comment: "Default")
|
||||||
private static let unknownValue = NSLocalizedString("Unknown", comment: "Unknown Value")
|
private static let unknownValue = NSLocalizedString("Unknown", comment: "Unknown Value")
|
||||||
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let template: String?
|
let template: String?
|
||||||
let css: String?
|
let css: String?
|
||||||
let isAppTheme: Bool
|
let isAppTheme: Bool
|
||||||
|
|
||||||
var name: String {
|
var name: String {
|
||||||
guard let url else { return Self.defaultThemeName }
|
guard let url else { return Self.defaultThemeName }
|
||||||
return Self.themeNameForPath(url.path)
|
return Self.themeNameForPath(url.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
var creatorHomePage: String {
|
var creatorHomePage: String {
|
||||||
return info?.creatorHomePage ?? Self.unknownValue
|
return info?.creatorHomePage ?? Self.unknownValue
|
||||||
}
|
}
|
||||||
|
|
||||||
var creatorName: String {
|
var creatorName: String {
|
||||||
return info?.creatorName ?? Self.unknownValue
|
return info?.creatorName ?? Self.unknownValue
|
||||||
}
|
}
|
||||||
|
|
||||||
var version: String {
|
var version: String {
|
||||||
return String(describing: info?.version ?? 0)
|
return String(describing: info?.version ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let info: ArticleThemePlist?
|
private let info: ArticleThemePlist?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.url = nil
|
self.url = nil
|
||||||
self.info = ArticleThemePlist(name: "Article Theme", themeIdentifier: "com.ranchero.netnewswire.theme.article", creatorHomePage: "https://netnewswire.com/", creatorName: "Ranchero Software", version: 1)
|
self.info = ArticleThemePlist(name: "Article Theme", themeIdentifier: "com.ranchero.netnewswire.theme.article", creatorHomePage: "https://netnewswire.com/", creatorName: "Ranchero Software", version: 1)
|
||||||
|
|
||||||
let corePath = Bundle.main.path(forResource: "core", ofType: "css")!
|
let corePath = Bundle.main.path(forResource: "core", ofType: "css")!
|
||||||
let stylesheetPath = Bundle.main.path(forResource: "stylesheet", ofType: "css")!
|
let stylesheetPath = Bundle.main.path(forResource: "stylesheet", ofType: "css")!
|
||||||
css = Self.stringAtPath(corePath)! + "\n" + Self.stringAtPath(stylesheetPath)!
|
css = Self.stringAtPath(corePath)! + "\n" + Self.stringAtPath(stylesheetPath)!
|
||||||
|
|
||||||
let templatePath = Bundle.main.path(forResource: "template", ofType: "html")!
|
let templatePath = Bundle.main.path(forResource: "template", ofType: "html")!
|
||||||
template = Self.stringAtPath(templatePath)!
|
template = Self.stringAtPath(templatePath)!
|
||||||
|
|
||||||
isAppTheme = true
|
isAppTheme = true
|
||||||
}
|
}
|
||||||
|
|
||||||
init(url: URL, isAppTheme: Bool) throws {
|
init(url: URL, isAppTheme: Bool) throws {
|
||||||
|
|
||||||
_ = url.startAccessingSecurityScopedResource()
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
|
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
let coreURL = Bundle.main.url(forResource: "core", withExtension: "css")!
|
let coreURL = Bundle.main.url(forResource: "core", withExtension: "css")!
|
||||||
@ -85,25 +85,25 @@ struct ArticleTheme: Equatable {
|
|||||||
if !FileManager.default.fileExists(atPath: f) {
|
if !FileManager.default.fileExists(atPath: f) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let s = try? NSString(contentsOfFile: f, usedEncoding: nil) as String {
|
if let s = try? NSString(contentsOfFile: f, usedEncoding: nil) as String {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func filenameWithThemeSuffixRemoved(_ filename: String) -> String {
|
static func filenameWithThemeSuffixRemoved(_ filename: String) -> String {
|
||||||
return filename.stripping(suffix: Self.nnwThemeSuffix)
|
return filename.stripping(suffix: Self.nnwThemeSuffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func themeNameForPath(_ f: String) -> String {
|
static func themeNameForPath(_ f: String) -> String {
|
||||||
let filename = (f as NSString).lastPathComponent
|
let filename = (f as NSString).lastPathComponent
|
||||||
return filenameWithThemeSuffixRemoved(filename)
|
return filenameWithThemeSuffixRemoved(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pathIsPathForThemeName(_ themeName: String, path: String) -> Bool {
|
static func pathIsPathForThemeName(_ themeName: String, path: String) -> Bool {
|
||||||
let filename = (path as NSString).lastPathComponent
|
let filename = (path as NSString).lastPathComponent
|
||||||
return filenameWithThemeSuffixRemoved(filename) == themeName
|
return filenameWithThemeSuffixRemoved(filename) == themeName
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -10,10 +10,10 @@ import Foundation
|
|||||||
import Zip
|
import Zip
|
||||||
|
|
||||||
public class ArticleThemeDownloader {
|
public class ArticleThemeDownloader {
|
||||||
|
|
||||||
public enum ArticleThemeDownloaderError: LocalizedError {
|
public enum ArticleThemeDownloaderError: LocalizedError {
|
||||||
case noThemeFile
|
case noThemeFile
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .noThemeFile:
|
case .noThemeFile:
|
||||||
@ -21,23 +21,22 @@ public class ArticleThemeDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let shared = ArticleThemeDownloader()
|
public static let shared = ArticleThemeDownloader()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
public func handleFile(at location: URL) throws {
|
public func handleFile(at location: URL) throws {
|
||||||
createDownloadDirectoryIfRequired()
|
createDownloadDirectoryIfRequired()
|
||||||
let movedFileLocation = try moveTheme(from: location)
|
let movedFileLocation = try moveTheme(from: location)
|
||||||
let unzippedFileLocation = try unzipFile(at: movedFileLocation)
|
let unzippedFileLocation = try unzipFile(at: movedFileLocation)
|
||||||
NotificationCenter.default.post(name: .didEndDownloadingTheme, object: nil, userInfo: ["url" : unzippedFileLocation])
|
NotificationCenter.default.post(name: .didEndDownloadingTheme, object: nil, userInfo: ["url": unzippedFileLocation])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Creates `Application Support/NetNewsWire/Downloads` if needed.
|
/// Creates `Application Support/NetNewsWire/Downloads` if needed.
|
||||||
private func createDownloadDirectoryIfRequired() {
|
private func createDownloadDirectoryIfRequired() {
|
||||||
try? FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true, attributes: nil)
|
try? FileManager.default.createDirectory(at: downloadDirectory(), withIntermediateDirectories: true, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the downloaded `.tmp` file to the `downloadDirectory` and renames it a `.zip`
|
/// Moves the downloaded `.tmp` file to the `downloadDirectory` and renames it a `.zip`
|
||||||
/// - Parameter location: The temporary file location.
|
/// - Parameter location: The temporary file location.
|
||||||
/// - Returns: Destination `URL`.
|
/// - Returns: Destination `URL`.
|
||||||
@ -48,7 +47,7 @@ public class ArticleThemeDownloader {
|
|||||||
try FileManager.default.moveItem(at: location, to: fileUrl)
|
try FileManager.default.moveItem(at: location, to: fileUrl)
|
||||||
return fileUrl
|
return fileUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unzips the zip file
|
/// Unzips the zip file
|
||||||
/// - Parameter location: Location of the zip archive.
|
/// - Parameter location: Location of the zip archive.
|
||||||
/// - Returns: Enclosed `.nnwtheme` file.
|
/// - Returns: Enclosed `.nnwtheme` file.
|
||||||
@ -67,8 +66,7 @@ public class ArticleThemeDownloader {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Performs a deep search of the unzipped directory to find the theme file.
|
/// Performs a deep search of the unzipped directory to find the theme file.
|
||||||
/// - Parameter searchPath: directory to search
|
/// - Parameter searchPath: directory to search
|
||||||
/// - Returns: optional `String`
|
/// - Returns: optional `String`
|
||||||
@ -80,16 +78,16 @@ public class ArticleThemeDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The download directory used by the theme downloader: `Application Support/NetNewsWire/Downloads`
|
/// The download directory used by the theme downloader: `Application Support/NetNewsWire/Downloads`
|
||||||
/// - Returns: `URL`
|
/// - Returns: `URL`
|
||||||
private func downloadDirectory() -> URL {
|
private func downloadDirectory() -> URL {
|
||||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("NetNewsWire/Downloads", isDirectory: true)
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("NetNewsWire/Downloads", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes downloaded themes, where themes == folders, from `Application Support/NetNewsWire/Downloads`.
|
/// Removes downloaded themes, where themes == folders, from `Application Support/NetNewsWire/Downloads`.
|
||||||
public func cleanUp() {
|
public func cleanUp() {
|
||||||
guard let filenames = try? FileManager.default.contentsOfDirectory(atPath: downloadDirectory().path) else {
|
guard let filenames = try? FileManager.default.contentsOfDirectory(atPath: downloadDirectory().path) else {
|
||||||
|
@ -14,7 +14,7 @@ public struct ArticleThemePlist: Codable, Equatable {
|
|||||||
public var creatorHomePage: String
|
public var creatorHomePage: String
|
||||||
public var creatorName: String
|
public var creatorName: String
|
||||||
public var version: Int
|
public var version: Int
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name = "Name"
|
case name = "Name"
|
||||||
case themeIdentifier = "ThemeIdentifier"
|
case themeIdentifier = "ThemeIdentifier"
|
||||||
|
@ -54,49 +54,49 @@ final class ArticleThemesManager: NSObject, NSFilePresenter {
|
|||||||
self.currentTheme = ArticleTheme.defaultTheme
|
self.currentTheme = ArticleTheme.defaultTheme
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(atPath: folderPath, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(atPath: folderPath, withIntermediateDirectories: true, attributes: nil)
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure("Could not create folder for Themes.")
|
assertionFailure("Could not create folder for Themes.")
|
||||||
abort()
|
abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
updateThemeNames()
|
updateThemeNames()
|
||||||
updateCurrentTheme()
|
updateCurrentTheme()
|
||||||
|
|
||||||
NSFileCoordinator.addFilePresenter(self)
|
NSFileCoordinator.addFilePresenter(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentedSubitemDidChange(at url: URL) {
|
func presentedSubitemDidChange(at url: URL) {
|
||||||
updateThemeNames()
|
updateThemeNames()
|
||||||
updateCurrentTheme()
|
updateCurrentTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
func themeExists(filename: String) -> Bool {
|
func themeExists(filename: String) -> Bool {
|
||||||
let filenameLastPathComponent = (filename as NSString).lastPathComponent
|
let filenameLastPathComponent = (filename as NSString).lastPathComponent
|
||||||
let toFilename = (folderPath as NSString).appendingPathComponent(filenameLastPathComponent)
|
let toFilename = (folderPath as NSString).appendingPathComponent(filenameLastPathComponent)
|
||||||
return FileManager.default.fileExists(atPath: toFilename)
|
return FileManager.default.fileExists(atPath: toFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func importTheme(filename: String) throws {
|
func importTheme(filename: String) throws {
|
||||||
let filenameLastPathComponent = (filename as NSString).lastPathComponent
|
let filenameLastPathComponent = (filename as NSString).lastPathComponent
|
||||||
let toFilename = (folderPath as NSString).appendingPathComponent(filenameLastPathComponent)
|
let toFilename = (folderPath as NSString).appendingPathComponent(filenameLastPathComponent)
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: toFilename) {
|
if FileManager.default.fileExists(atPath: toFilename) {
|
||||||
try FileManager.default.removeItem(atPath: toFilename)
|
try FileManager.default.removeItem(atPath: toFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.copyItem(atPath: filename, toPath: toFilename)
|
try FileManager.default.copyItem(atPath: filename, toPath: toFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func articleThemeWithThemeName(_ themeName: String) -> ArticleTheme? {
|
func articleThemeWithThemeName(_ themeName: String) -> ArticleTheme? {
|
||||||
if themeName == AppDefaults.defaultThemeName {
|
if themeName == AppDefaults.defaultThemeName {
|
||||||
return ArticleTheme.defaultTheme
|
return ArticleTheme.defaultTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
let url: URL
|
let url: URL
|
||||||
let isAppTheme: Bool
|
let isAppTheme: Bool
|
||||||
if let appThemeURL = Bundle.main.url(forResource: themeName, withExtension: ArticleTheme.nnwThemeSuffix) {
|
if let appThemeURL = Bundle.main.url(forResource: themeName, withExtension: ArticleTheme.nnwThemeSuffix) {
|
||||||
@ -108,14 +108,14 @@ final class ArticleThemesManager: NSObject, NSFilePresenter {
|
|||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try ArticleTheme(url: url, isAppTheme: isAppTheme)
|
return try ArticleTheme(url: url, isAppTheme: isAppTheme)
|
||||||
} catch {
|
} catch {
|
||||||
NotificationCenter.default.post(name: .didFailToImportThemeWithError, object: nil, userInfo: ["error": error])
|
NotificationCenter.default.post(name: .didFailToImportThemeWithError, object: nil, userInfo: ["error": error])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteTheme(themeName: String) {
|
func deleteTheme(themeName: String) {
|
||||||
@ -123,10 +123,10 @@ final class ArticleThemesManager: NSObject, NSFilePresenter {
|
|||||||
try? FileManager.default.removeItem(atPath: filename)
|
try? FileManager.default.removeItem(atPath: filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK : Private
|
// MARK: Private
|
||||||
|
|
||||||
private extension ArticleThemesManager {
|
private extension ArticleThemesManager {
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ private extension ArticleThemesManager {
|
|||||||
let installedThemeNames = Set(allThemePaths(folderPath).map { ArticleTheme.themeNameForPath($0) })
|
let installedThemeNames = Set(allThemePaths(folderPath).map { ArticleTheme.themeNameForPath($0) })
|
||||||
|
|
||||||
let allThemeNames = appThemeNames.union(installedThemeNames)
|
let allThemeNames = appThemeNames.union(installedThemeNames)
|
||||||
|
|
||||||
let sortedThemeNames = allThemeNames.sorted(by: { $0.compare($1, options: .caseInsensitive) == .orderedAscending })
|
let sortedThemeNames = allThemeNames.sorted(by: { $0.compare($1, options: .caseInsensitive) == .orderedAscending })
|
||||||
if sortedThemeNames != themeNames {
|
if sortedThemeNames != themeNames {
|
||||||
themeNames = sortedThemeNames
|
themeNames = sortedThemeNames
|
||||||
@ -179,5 +179,5 @@ private extension ArticleThemesManager {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -20,11 +20,11 @@ final class DeleteCommand: UndoableCommand {
|
|||||||
var redoActionName: String {
|
var redoActionName: String {
|
||||||
return undoActionName
|
return undoActionName
|
||||||
}
|
}
|
||||||
let errorHandler: (Error) -> ()
|
let errorHandler: (Error) -> Void
|
||||||
|
|
||||||
private let itemSpecifiers: [SidebarItemSpecifier]
|
private let itemSpecifiers: [SidebarItemSpecifier]
|
||||||
|
|
||||||
init?(nodesToDelete: [Node], treeController: TreeController? = nil, undoManager: UndoManager, errorHandler: @escaping (Error) -> ()) {
|
init?(nodesToDelete: [Node], treeController: TreeController? = nil, undoManager: UndoManager, errorHandler: @escaping (Error) -> Void) {
|
||||||
|
|
||||||
guard DeleteCommand.canDelete(nodesToDelete) else {
|
guard DeleteCommand.canDelete(nodesToDelete) else {
|
||||||
return nil
|
return nil
|
||||||
@ -38,7 +38,7 @@ final class DeleteCommand: UndoableCommand {
|
|||||||
self.undoManager = undoManager
|
self.undoManager = undoManager
|
||||||
self.errorHandler = errorHandler
|
self.errorHandler = errorHandler
|
||||||
|
|
||||||
let itemSpecifiers = nodesToDelete.compactMap{ SidebarItemSpecifier(node: $0, errorHandler: errorHandler) }
|
let itemSpecifiers = nodesToDelete.compactMap { SidebarItemSpecifier(node: $0, errorHandler: errorHandler) }
|
||||||
guard !itemSpecifiers.isEmpty else {
|
guard !itemSpecifiers.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -46,21 +46,21 @@ final class DeleteCommand: UndoableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func perform() {
|
func perform() {
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
for itemSpecifier in itemSpecifiers {
|
for itemSpecifier in itemSpecifiers {
|
||||||
group.enter()
|
group.enter()
|
||||||
itemSpecifier.delete() {
|
itemSpecifier.delete {
|
||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
group.notify(queue: DispatchQueue.main) {
|
group.notify(queue: DispatchQueue.main) {
|
||||||
self.treeController?.rebuild()
|
self.treeController?.rebuild()
|
||||||
self.registerUndo()
|
self.registerUndo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func undo() {
|
func undo() {
|
||||||
for itemSpecifier in itemSpecifiers {
|
for itemSpecifier in itemSpecifiers {
|
||||||
itemSpecifier.restore()
|
itemSpecifier.restore()
|
||||||
@ -101,7 +101,7 @@ private struct SidebarItemSpecifier {
|
|||||||
private let folder: Folder?
|
private let folder: Folder?
|
||||||
private let feed: Feed?
|
private let feed: Feed?
|
||||||
private let path: ContainerPath
|
private let path: ContainerPath
|
||||||
private let errorHandler: (Error) -> ()
|
private let errorHandler: (Error) -> Void
|
||||||
|
|
||||||
private var container: Container? {
|
private var container: Container? {
|
||||||
if let parentFolder = parentFolder {
|
if let parentFolder = parentFolder {
|
||||||
@ -113,7 +113,7 @@ private struct SidebarItemSpecifier {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(node: Node, errorHandler: @escaping (Error) -> ()) {
|
init?(node: Node, errorHandler: @escaping (Error) -> Void) {
|
||||||
|
|
||||||
var account: Account?
|
var account: Account?
|
||||||
|
|
||||||
@ -123,13 +123,11 @@ private struct SidebarItemSpecifier {
|
|||||||
self.feed = feed
|
self.feed = feed
|
||||||
self.folder = nil
|
self.folder = nil
|
||||||
account = feed.account
|
account = feed.account
|
||||||
}
|
} else if let folder = node.representedObject as? Folder {
|
||||||
else if let folder = node.representedObject as? Folder {
|
|
||||||
self.feed = nil
|
self.feed = nil
|
||||||
self.folder = folder
|
self.folder = folder
|
||||||
account = folder.account
|
account = folder.account
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if account == nil {
|
if account == nil {
|
||||||
@ -138,36 +136,36 @@ private struct SidebarItemSpecifier {
|
|||||||
|
|
||||||
self.account = account!
|
self.account = account!
|
||||||
self.path = ContainerPath(account: account!, folders: node.containingFolders())
|
self.path = ContainerPath(account: account!, folders: node.containingFolders())
|
||||||
|
|
||||||
self.errorHandler = errorHandler
|
self.errorHandler = errorHandler
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(completion: @escaping () -> Void) {
|
func delete(completion: @escaping () -> Void) {
|
||||||
|
|
||||||
if let feed = feed {
|
if let feed = feed {
|
||||||
|
|
||||||
guard let container = path.resolveContainer() else {
|
guard let container = path.resolveContainer() else {
|
||||||
completion()
|
completion()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
BatchUpdate.shared.start()
|
BatchUpdate.shared.start()
|
||||||
account?.removeFeed(feed, from: container) { result in
|
account?.removeFeed(feed, from: container) { result in
|
||||||
BatchUpdate.shared.end()
|
BatchUpdate.shared.end()
|
||||||
completion()
|
completion()
|
||||||
self.checkResult(result)
|
self.checkResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if let folder = folder {
|
} else if let folder = folder {
|
||||||
|
|
||||||
BatchUpdate.shared.start()
|
BatchUpdate.shared.start()
|
||||||
account?.removeFolder(folder) { result in
|
account?.removeFolder(folder) { result in
|
||||||
BatchUpdate.shared.end()
|
BatchUpdate.shared.end()
|
||||||
completion()
|
completion()
|
||||||
self.checkResult(result)
|
self.checkResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,8 +173,7 @@ private struct SidebarItemSpecifier {
|
|||||||
|
|
||||||
if let _ = feed {
|
if let _ = feed {
|
||||||
restoreFeed()
|
restoreFeed()
|
||||||
}
|
} else if let _ = folder {
|
||||||
else if let _ = folder {
|
|
||||||
restoreFolder()
|
restoreFolder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -186,13 +183,13 @@ private struct SidebarItemSpecifier {
|
|||||||
guard let account = account, let feed = feed, let container = path.resolveContainer() else {
|
guard let account = account, let feed = feed, let container = path.resolveContainer() else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
BatchUpdate.shared.start()
|
BatchUpdate.shared.start()
|
||||||
account.restoreFeed(feed, container: container) { result in
|
account.restoreFeed(feed, container: container) { result in
|
||||||
BatchUpdate.shared.end()
|
BatchUpdate.shared.end()
|
||||||
self.checkResult(result)
|
self.checkResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreFolder() {
|
private func restoreFolder() {
|
||||||
@ -200,17 +197,17 @@ private struct SidebarItemSpecifier {
|
|||||||
guard let account = account, let folder = folder else {
|
guard let account = account, let folder = folder else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
BatchUpdate.shared.start()
|
BatchUpdate.shared.start()
|
||||||
account.restoreFolder(folder) { result in
|
account.restoreFolder(folder) { result in
|
||||||
BatchUpdate.shared.end()
|
BatchUpdate.shared.end()
|
||||||
self.checkResult(result)
|
self.checkResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkResult(_ result: Result<Void, Error>) {
|
private func checkResult(_ result: Result<Void, Error>) {
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
break
|
break
|
||||||
@ -219,11 +216,11 @@ private struct SidebarItemSpecifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Node {
|
private extension Node {
|
||||||
|
|
||||||
func parentFolder() -> Folder? {
|
func parentFolder() -> Folder? {
|
||||||
|
|
||||||
guard let parentNode = self.parent else {
|
guard let parentNode = self.parent else {
|
||||||
@ -246,8 +243,7 @@ private extension Node {
|
|||||||
while nomad != nil {
|
while nomad != nil {
|
||||||
if let folder = nomad!.representedObject as? Folder {
|
if let folder = nomad!.representedObject as? Folder {
|
||||||
folders += [folder]
|
folders += [folder]
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
nomad = nomad!.parent
|
nomad = nomad!.parent
|
||||||
@ -274,11 +270,9 @@ private struct DeleteActionName {
|
|||||||
for node in nodes {
|
for node in nodes {
|
||||||
if let _ = node.representedObject as? Feed {
|
if let _ = node.representedObject as? Feed {
|
||||||
numberOfFeeds += 1
|
numberOfFeeds += 1
|
||||||
}
|
} else if let _ = node.representedObject as? Folder {
|
||||||
else if let _ = node.representedObject as? Folder {
|
|
||||||
numberOfFolders += 1
|
numberOfFolders += 1
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
return nil // Delete only Feeds and Folders.
|
return nil // Delete only Feeds and Folders.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,17 +13,17 @@ import Articles
|
|||||||
// Mark articles read/unread, starred/unstarred, deleted/undeleted.
|
// Mark articles read/unread, starred/unstarred, deleted/undeleted.
|
||||||
|
|
||||||
final class MarkStatusCommand: UndoableCommand {
|
final class MarkStatusCommand: UndoableCommand {
|
||||||
|
|
||||||
let undoActionName: String
|
let undoActionName: String
|
||||||
let redoActionName: String
|
let redoActionName: String
|
||||||
let articles: Set<Article>
|
let articles: Set<Article>
|
||||||
let undoManager: UndoManager
|
let undoManager: UndoManager
|
||||||
let flag: Bool
|
let flag: Bool
|
||||||
let statusKey: ArticleStatus.Key
|
let statusKey: ArticleStatus.Key
|
||||||
var completion: (() -> Void)? = nil
|
var completion: (() -> Void)?
|
||||||
|
|
||||||
init?(initialArticles: [Article], statusKey: ArticleStatus.Key, flag: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) {
|
init?(initialArticles: [Article], statusKey: ArticleStatus.Key, flag: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) {
|
||||||
|
|
||||||
// Filter out articles that already have the desired status or can't be marked.
|
// Filter out articles that already have the desired status or can't be marked.
|
||||||
let articlesToMark = MarkStatusCommand.filteredArticles(initialArticles, statusKey, flag)
|
let articlesToMark = MarkStatusCommand.filteredArticles(initialArticles, statusKey, flag)
|
||||||
if articlesToMark.isEmpty {
|
if articlesToMark.isEmpty {
|
||||||
@ -54,7 +54,7 @@ final class MarkStatusCommand: UndoableCommand {
|
|||||||
mark(statusKey, flag)
|
mark(statusKey, flag)
|
||||||
registerUndo()
|
registerUndo()
|
||||||
}
|
}
|
||||||
|
|
||||||
func undo() {
|
func undo() {
|
||||||
mark(statusKey, !flag)
|
mark(statusKey, !flag)
|
||||||
registerRedo()
|
registerRedo()
|
||||||
@ -62,7 +62,7 @@ final class MarkStatusCommand: UndoableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension MarkStatusCommand {
|
private extension MarkStatusCommand {
|
||||||
|
|
||||||
func mark(_ statusKey: ArticleStatus.Key, _ flag: Bool) {
|
func mark(_ statusKey: ArticleStatus.Key, _ flag: Bool) {
|
||||||
markArticles(articles, statusKey: statusKey, flag: flag, completion: completion)
|
markArticles(articles, statusKey: statusKey, flag: flag, completion: completion)
|
||||||
completion = nil
|
completion = nil
|
||||||
@ -85,12 +85,12 @@ private extension MarkStatusCommand {
|
|||||||
|
|
||||||
static func filteredArticles(_ articles: [Article], _ statusKey: ArticleStatus.Key, _ flag: Bool) -> [Article] {
|
static func filteredArticles(_ articles: [Article], _ statusKey: ArticleStatus.Key, _ flag: Bool) -> [Article] {
|
||||||
|
|
||||||
return articles.filter{ article in
|
return articles.filter { article in
|
||||||
guard article.status.boolStatus(forKey: statusKey) != flag else { return false }
|
guard article.status.boolStatus(forKey: statusKey) != flag else { return false }
|
||||||
guard statusKey == .read else { return true }
|
guard statusKey == .read else { return true }
|
||||||
guard !article.status.read || article.isAvailableToMarkUnread else { return false }
|
guard !article.status.read || article.isAvailableToMarkUnread else { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
|
|
||||||
static let prototypeText = "You are about to being reading Italo Calvino’s new novel, *If on a winter’s night a traveler*. Relax. Concentrate. Dispel every other thought. Let the world around you fade. Best to close the door; the TV is always on in the next room. Tell the others right away, “No, I don’t want to watch TV!” Raise your voice—they won’t hear you otherwise—“I’m reading! I don’t want to be disturbed!” Maybe they haven’t heard you, with all that racket; speak louder, yell: “I’m beginning to read Italo Calvino’s new novel!” Or if you prefer, don’t say anything; just hope they’ll leave you alone. Find the most comfortable position: seated, stretched out, curled up, or lying flat. Flat on your back, on your side, on your stomach. In an easy chair, on the sofa, in the rocker, the deck chair, on the hassock. In the hammock, if you have a hammock. On top of your bed, of course, or in the bed. You can even stand on your hands, head down, in the yoga position. With the book upside down, naturally."
|
static let prototypeText = "You are about to being reading Italo Calvino’s new novel, *If on a winter’s night a traveler*. Relax. Concentrate. Dispel every other thought. Let the world around you fade. Best to close the door; the TV is always on in the next room. Tell the others right away, “No, I don’t want to watch TV!” Raise your voice—they won’t hear you otherwise—“I’m reading! I don’t want to be disturbed!” Maybe they haven’t heard you, with all that racket; speak louder, yell: “I’m beginning to read Italo Calvino’s new novel!” Or if you prefer, don’t say anything; just hope they’ll leave you alone. Find the most comfortable position: seated, stretched out, curled up, or lying flat. Flat on your back, on your side, on your stomach. In an easy chair, on the sofa, in the rocker, the deck chair, on the hassock. In the hammock, if you have a hammock. On top of your bed, of course, or in the bed. You can even stand on your hands, head down, in the yoga position. With the book upside down, naturally."
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ struct OPMLExporter {
|
|||||||
<title>\(escapedTitle)</title>
|
<title>\(escapedTitle)</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let middleText = account.OPMLString(indentLevel: 0)
|
let middleText = account.OPMLString(indentLevel: 0)
|
||||||
|
@ -57,9 +57,9 @@ private extension SendToMarsEditCommand {
|
|||||||
let authorName = article.authors?.first?.name
|
let authorName = article.authors?.first?.name
|
||||||
|
|
||||||
let sender = SendToBlogEditorApp(targetDescriptor: targetDescriptor, title: article.title, body: body, summary: article.summary, link: article.externalLink, permalink: article.link, subject: nil, creator: authorName, commentsURL: nil, guid: article.uniqueID, sourceName: article.feed?.nameForDisplay, sourceHomeURL: article.feed?.homePageURL, sourceFeedURL: article.feed?.url)
|
let sender = SendToBlogEditorApp(targetDescriptor: targetDescriptor, title: article.title, body: body, summary: article.summary, link: article.externalLink, permalink: article.link, subject: nil, creator: authorName, commentsURL: nil, guid: article.uniqueID, sourceName: article.feed?.nameForDisplay, sourceHomeURL: article.feed?.homePageURL, sourceFeedURL: article.feed?.url)
|
||||||
let _ = sender.send()
|
sender.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
func appToUse() -> UserApp? {
|
func appToUse() -> UserApp? {
|
||||||
|
|
||||||
for app in marsEditApps {
|
for app in marsEditApps {
|
||||||
|
@ -28,7 +28,7 @@ final class SendToMicroBlogCommand: SendToCommand {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendObject(_ object: Any?, selectedText: String?) {
|
func sendObject(_ object: Any?, selectedText: String?) {
|
||||||
|
|
||||||
guard canSendObject(object, selectedText: selectedText) else {
|
guard canSendObject(object, selectedText: selectedText) else {
|
||||||
|
@ -10,9 +10,9 @@ import Foundation
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
struct AddFeedDefaultContainer {
|
struct AddFeedDefaultContainer {
|
||||||
|
|
||||||
static var defaultContainer: Container? {
|
static var defaultContainer: Container? {
|
||||||
|
|
||||||
if let accountID = AppDefaults.shared.addFeedAccountID, let account = AccountManager.shared.activeAccounts.first(where: { $0.accountID == accountID }) {
|
if let accountID = AppDefaults.shared.addFeedAccountID, let account = AccountManager.shared.activeAccounts.first(where: { $0.accountID == accountID }) {
|
||||||
if let folderName = AppDefaults.shared.addFeedFolderName, let folder = account.existingFolder(withDisplayName: folderName) {
|
if let folderName = AppDefaults.shared.addFeedFolderName, let folder = account.existingFolder(withDisplayName: folderName) {
|
||||||
return folder
|
return folder
|
||||||
@ -24,9 +24,9 @@ struct AddFeedDefaultContainer {
|
|||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveDefaultContainer(_ container: Container) {
|
static func saveDefaultContainer(_ container: Container) {
|
||||||
AppDefaults.shared.addFeedAccountID = container.account?.accountID
|
AppDefaults.shared.addFeedAccountID = container.account?.accountID
|
||||||
if let folder = container as? Folder {
|
if let folder = container as? Folder {
|
||||||
@ -35,7 +35,7 @@ struct AddFeedDefaultContainer {
|
|||||||
AppDefaults.shared.addFeedFolderName = nil
|
AppDefaults.shared.addFeedFolderName = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func substituteContainerIfNeeded(account: Account) -> Container? {
|
private static func substituteContainerIfNeeded(account: Account) -> Container? {
|
||||||
if !account.behaviors.contains(.disallowFeedInRootFolder) {
|
if !account.behaviors.contains(.disallowFeedInRootFolder) {
|
||||||
return account
|
return account
|
||||||
@ -47,5 +47,5 @@ struct AddFeedDefaultContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -116,4 +116,3 @@ struct ArticleStringFormatter {
|
|||||||
return dateFormatter.string(from: date)
|
return dateFormatter.string(from: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,11 +14,11 @@ import Account
|
|||||||
// These handle multiple accounts.
|
// These handle multiple accounts.
|
||||||
|
|
||||||
func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) {
|
func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) {
|
||||||
|
|
||||||
let d: [String: Set<Article>] = accountAndArticlesDictionary(articles)
|
let d: [String: Set<Article>] = accountAndArticlesDictionary(articles)
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
|
|
||||||
for (accountID, accountArticles) in d {
|
for (accountID, accountArticles) in d {
|
||||||
guard let account = AccountManager.shared.existingAccount(with: accountID) else {
|
guard let account = AccountManager.shared.existingAccount(with: accountID) else {
|
||||||
continue
|
continue
|
||||||
@ -28,42 +28,42 @@ func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag:
|
|||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
group.notify(queue: .main) {
|
group.notify(queue: .main) {
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func accountAndArticlesDictionary(_ articles: Set<Article>) -> [String: Set<Article>] {
|
private func accountAndArticlesDictionary(_ articles: Set<Article>) -> [String: Set<Article>] {
|
||||||
|
|
||||||
let d = Dictionary(grouping: articles, by: { $0.accountID })
|
let d = Dictionary(grouping: articles, by: { $0.accountID })
|
||||||
return d.mapValues{ Set($0) }
|
return d.mapValues { Set($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Article {
|
extension Article {
|
||||||
|
|
||||||
var feed: Feed? {
|
var feed: Feed? {
|
||||||
return account?.existingFeed(withFeedID: feedID)
|
return account?.existingFeed(withFeedID: feedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var url: URL? {
|
var url: URL? {
|
||||||
return URL.reparingIfRequired(rawLink)
|
return URL.reparingIfRequired(rawLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
var externalURL: URL? {
|
var externalURL: URL? {
|
||||||
return URL.reparingIfRequired(rawExternalLink)
|
return URL.reparingIfRequired(rawExternalLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageURL: URL? {
|
var imageURL: URL? {
|
||||||
return URL.reparingIfRequired(rawImageLink)
|
return URL.reparingIfRequired(rawImageLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
var link: String? {
|
var link: String? {
|
||||||
// Prefer link from URL, if one can be created, as these are repaired if required.
|
// Prefer link from URL, if one can be created, as these are repaired if required.
|
||||||
// Provide the raw link if URL creation fails.
|
// Provide the raw link if URL creation fails.
|
||||||
return url?.absoluteString ?? rawLink
|
return url?.absoluteString ?? rawLink
|
||||||
}
|
}
|
||||||
|
|
||||||
var externalLink: String? {
|
var externalLink: String? {
|
||||||
// Prefer link from externalURL, if one can be created, as these are repaired if required.
|
// Prefer link from externalURL, if one can be created, as these are repaired if required.
|
||||||
// Provide the raw link if URL creation fails.
|
// Provide the raw link if URL creation fails.
|
||||||
@ -85,19 +85,19 @@ extension Article {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var preferredURL: URL? {
|
var preferredURL: URL? {
|
||||||
return url ?? externalURL
|
return url ?? externalURL
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: String? {
|
var body: String? {
|
||||||
return contentHTML ?? contentText ?? summary
|
return contentHTML ?? contentText ?? summary
|
||||||
}
|
}
|
||||||
|
|
||||||
var logicalDatePublished: Date {
|
var logicalDatePublished: Date {
|
||||||
return datePublished ?? dateModified ?? status.dateArrived
|
return datePublished ?? dateModified ?? status.dateArrived
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAvailableToMarkUnread: Bool {
|
var isAvailableToMarkUnread: Bool {
|
||||||
guard let markUnreadWindow = account?.behaviors.compactMap( { behavior -> Int? in
|
guard let markUnreadWindow = account?.behaviors.compactMap( { behavior -> Int? in
|
||||||
switch behavior {
|
switch behavior {
|
||||||
@ -109,7 +109,7 @@ extension Article {
|
|||||||
}).first else {
|
}).first else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if logicalDatePublished.byAdding(days: markUnreadWindow) > Date() {
|
if logicalDatePublished.byAdding(days: markUnreadWindow) > Date() {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@ -120,7 +120,7 @@ extension Article {
|
|||||||
func iconImage() -> IconImage? {
|
func iconImage() -> IconImage? {
|
||||||
return IconImageCache.shared.imageForArticle(self)
|
return IconImageCache.shared.imageForArticle(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func iconImageUrl(feed: Feed) -> URL? {
|
func iconImageUrl(feed: Feed) -> URL? {
|
||||||
if let image = iconImage() {
|
if let image = iconImage() {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
@ -137,7 +137,7 @@ extension Article {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func byline() -> String {
|
func byline() -> String {
|
||||||
guard let authors = authors ?? feed?.authors, !authors.isEmpty else {
|
guard let authors = authors ?? feed?.authors, !authors.isEmpty else {
|
||||||
return ""
|
return ""
|
||||||
@ -151,7 +151,7 @@ extension Article {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var byline = ""
|
var byline = ""
|
||||||
var isFirstAuthor = true
|
var isFirstAuthor = true
|
||||||
|
|
||||||
@ -160,32 +160,28 @@ extension Article {
|
|||||||
byline += ", "
|
byline += ", "
|
||||||
}
|
}
|
||||||
isFirstAuthor = false
|
isFirstAuthor = false
|
||||||
|
|
||||||
var authorEmailAddress: String? = nil
|
var authorEmailAddress: String?
|
||||||
if let emailAddress = author.emailAddress, !(emailAddress.contains("noreply@") || emailAddress.contains("no-reply@")) {
|
if let emailAddress = author.emailAddress, !(emailAddress.contains("noreply@") || emailAddress.contains("no-reply@")) {
|
||||||
authorEmailAddress = emailAddress
|
authorEmailAddress = emailAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
if let emailAddress = authorEmailAddress, emailAddress.contains(" ") {
|
if let emailAddress = authorEmailAddress, emailAddress.contains(" ") {
|
||||||
byline += emailAddress // probably name plus email address
|
byline += emailAddress // probably name plus email address
|
||||||
}
|
} else if let name = author.name, let emailAddress = authorEmailAddress {
|
||||||
else if let name = author.name, let emailAddress = authorEmailAddress {
|
|
||||||
byline += "\(name) <\(emailAddress)>"
|
byline += "\(name) <\(emailAddress)>"
|
||||||
}
|
} else if let name = author.name {
|
||||||
else if let name = author.name {
|
|
||||||
byline += name
|
byline += name
|
||||||
}
|
} else if let emailAddress = authorEmailAddress {
|
||||||
else if let emailAddress = authorEmailAddress {
|
|
||||||
byline += "<\(emailAddress)>"
|
byline += "<\(emailAddress)>"
|
||||||
}
|
} else if let url = author.url {
|
||||||
else if let url = author.url {
|
|
||||||
byline += url
|
byline += url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return byline
|
return byline
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Path
|
// MARK: Path
|
||||||
@ -199,7 +195,7 @@ struct ArticlePathKey {
|
|||||||
|
|
||||||
extension Article {
|
extension Article {
|
||||||
|
|
||||||
public var pathUserInfo: [AnyHashable : Any] {
|
public var pathUserInfo: [AnyHashable: Any] {
|
||||||
return [
|
return [
|
||||||
ArticlePathKey.accountID: accountID,
|
ArticlePathKey.accountID: accountID,
|
||||||
ArticlePathKey.accountName: account?.nameForDisplay ?? "",
|
ArticlePathKey.accountName: account?.nameForDisplay ?? "",
|
||||||
@ -213,21 +209,21 @@ extension Article {
|
|||||||
// MARK: SortableArticle
|
// MARK: SortableArticle
|
||||||
|
|
||||||
extension Article: SortableArticle {
|
extension Article: SortableArticle {
|
||||||
|
|
||||||
var sortableName: String {
|
var sortableName: String {
|
||||||
return feed?.name ?? ""
|
return feed?.name ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var sortableDate: Date {
|
var sortableDate: Date {
|
||||||
return logicalDatePublished
|
return logicalDatePublished
|
||||||
}
|
}
|
||||||
|
|
||||||
var sortableArticleID: String {
|
var sortableArticleID: String {
|
||||||
return articleID
|
return articleID
|
||||||
}
|
}
|
||||||
|
|
||||||
var sortableFeedID: String {
|
var sortableFeedID: String {
|
||||||
return feedID
|
return feedID
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ struct CacheCleaner {
|
|||||||
AppDefaults.shared.lastImageCacheFlushDate = Date()
|
AppDefaults.shared.lastImageCacheFlushDate = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the image disk cache hasn't been flushed for 3 days and the network is available, delete it
|
// If the image disk cache hasn't been flushed for 3 days and the network is available, delete it
|
||||||
if flushDate.addingTimeInterval(3600 * 24 * 3) < Date() {
|
if flushDate.addingTimeInterval(3600 * 24 * 3) < Date() {
|
||||||
if let reachability = try? Reachability(hostname: "apple.com") {
|
if let reachability = try? Reachability(hostname: "apple.com") {
|
||||||
@ -41,13 +41,13 @@ struct CacheCleaner {
|
|||||||
os_log(.error, log: self.log, "Could not delete cache file: %@", error.localizedDescription)
|
os_log(.error, log: self.log, "Could not delete cache file: %@", error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AppDefaults.shared.lastImageCacheFlushDate = Date()
|
AppDefaults.shared.lastImageCacheFlushDate = Date()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -15,15 +15,15 @@ import UIKit
|
|||||||
import RSCore
|
import RSCore
|
||||||
|
|
||||||
final class IconImage {
|
final class IconImage {
|
||||||
|
|
||||||
lazy var isDark: Bool = {
|
lazy var isDark: Bool = {
|
||||||
return image.isDark()
|
return image.isDark()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var isBright: Bool = {
|
lazy var isBright: Bool = {
|
||||||
return image.isBright()
|
return image.isBright()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let image: RSImage
|
let image: RSImage
|
||||||
let isSymbol: Bool
|
let isSymbol: Bool
|
||||||
let isBackgroundSuppressed: Bool
|
let isBackgroundSuppressed: Bool
|
||||||
@ -35,7 +35,7 @@ final class IconImage {
|
|||||||
self.preferredColor = preferredColor
|
self.preferredColor = preferredColor
|
||||||
self.isBackgroundSuppressed = isBackgroundSuppressed
|
self.isBackgroundSuppressed = isBackgroundSuppressed
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ -43,7 +43,7 @@ final class IconImage {
|
|||||||
func isDark() -> Bool {
|
func isDark() -> Bool {
|
||||||
return self.cgImage(forProposedRect: nil, context: nil, hints: nil)?.isDark() ?? false
|
return self.cgImage(forProposedRect: nil, context: nil, hints: nil)?.isDark() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBright() -> Bool {
|
func isBright() -> Bool {
|
||||||
return self.cgImage(forProposedRect: nil, context: nil, hints: nil)?.isBright() ?? false
|
return self.cgImage(forProposedRect: nil, context: nil, hints: nil)?.isBright() ?? false
|
||||||
}
|
}
|
||||||
@ -53,14 +53,14 @@ final class IconImage {
|
|||||||
func isDark() -> Bool {
|
func isDark() -> Bool {
|
||||||
return self.cgImage?.isDark() ?? false
|
return self.cgImage?.isDark() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBright() -> Bool {
|
func isBright() -> Bool {
|
||||||
return self.cgImage?.isBright() ?? false
|
return self.cgImage?.isBright() ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
fileprivate enum ImageLuminanceType {
|
private enum ImageLuminanceType {
|
||||||
case regular, bright, dark
|
case regular, bright, dark
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,18 +72,18 @@ extension CGImage {
|
|||||||
}
|
}
|
||||||
return luminanceType == .bright
|
return luminanceType == .bright
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDark() -> Bool {
|
func isDark() -> Bool {
|
||||||
guard let luminanceType = getLuminanceType() else {
|
guard let luminanceType = getLuminanceType() else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return luminanceType == .dark
|
return luminanceType == .dark
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func getLuminanceType() -> ImageLuminanceType? {
|
fileprivate func getLuminanceType() -> ImageLuminanceType? {
|
||||||
|
|
||||||
// This has been rewritten with information from https://christianselig.com/2021/04/efficient-average-color/
|
// This has been rewritten with information from https://christianselig.com/2021/04/efficient-average-color/
|
||||||
|
|
||||||
// First, resize the image. We do this for two reasons, 1) less pixels to deal with means faster
|
// First, resize the image. We do this for two reasons, 1) less pixels to deal with means faster
|
||||||
// calculation and a resized image still has the "gist" of the colors, and 2) the image we're dealing
|
// calculation and a resized image still has the "gist" of the colors, and 2) the image we're dealing
|
||||||
// with may come in any of a variety of color formats (CMYK, ARGB, RGBA, etc.) which complicates things,
|
// with may come in any of a variety of color formats (CMYK, ARGB, RGBA, etc.) which complicates things,
|
||||||
@ -91,32 +91,32 @@ extension CGImage {
|
|||||||
// 40x40 is a good size to resize to still preserve quite a bit of detail but not have too many pixels
|
// 40x40 is a good size to resize to still preserve quite a bit of detail but not have too many pixels
|
||||||
// to deal with. Aspect ratio is irrelevant for just finding average color.
|
// to deal with. Aspect ratio is irrelevant for just finding average color.
|
||||||
let size = CGSize(width: 40, height: 40)
|
let size = CGSize(width: 40, height: 40)
|
||||||
|
|
||||||
let width = Int(size.width)
|
let width = Int(size.width)
|
||||||
let height = Int(size.height)
|
let height = Int(size.height)
|
||||||
let totalPixels = width * height
|
let totalPixels = width * height
|
||||||
|
|
||||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
|
|
||||||
// ARGB format
|
// ARGB format
|
||||||
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue
|
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||||
|
|
||||||
// 8 bits for each color channel, we're doing ARGB so 32 bits (4 bytes) total, and thus if the image is n pixels wide,
|
// 8 bits for each color channel, we're doing ARGB so 32 bits (4 bytes) total, and thus if the image is n pixels wide,
|
||||||
// and has 4 bytes per pixel, the total bytes per row is 4n. That gives us 2^8 = 256 color variations for each RGB channel
|
// and has 4 bytes per pixel, the total bytes per row is 4n. That gives us 2^8 = 256 color variations for each RGB channel
|
||||||
// or 256 * 256 * 256 = ~16.7M color options in total. That seems like a lot, but lots of HDR movies are in 10 bit, which
|
// or 256 * 256 * 256 = ~16.7M color options in total. That seems like a lot, but lots of HDR movies are in 10 bit, which
|
||||||
// is (2^10)^3 = 1 billion color options!
|
// is (2^10)^3 = 1 billion color options!
|
||||||
guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: bitmapInfo) else { return nil }
|
guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: colorSpace, bitmapInfo: bitmapInfo) else { return nil }
|
||||||
|
|
||||||
// Draw our resized image
|
// Draw our resized image
|
||||||
context.draw(self, in: CGRect(origin: .zero, size: size))
|
context.draw(self, in: CGRect(origin: .zero, size: size))
|
||||||
|
|
||||||
guard let pixelBuffer = context.data else { return nil }
|
guard let pixelBuffer = context.data else { return nil }
|
||||||
|
|
||||||
// Bind the pixel buffer's memory location to a pointer we can use/access
|
// Bind the pixel buffer's memory location to a pointer we can use/access
|
||||||
let pointer = pixelBuffer.bindMemory(to: UInt32.self, capacity: width * height)
|
let pointer = pixelBuffer.bindMemory(to: UInt32.self, capacity: width * height)
|
||||||
|
|
||||||
var totalLuminance = 0.0
|
var totalLuminance = 0.0
|
||||||
|
|
||||||
// Column of pixels in image
|
// Column of pixels in image
|
||||||
for x in 0 ..< width {
|
for x in 0 ..< width {
|
||||||
// Row of pixels in image
|
// Row of pixels in image
|
||||||
@ -126,17 +126,17 @@ extension CGImage {
|
|||||||
// columns in to our "long row", we'd offset ourselves 15 times the width in pixels of the image, and
|
// columns in to our "long row", we'd offset ourselves 15 times the width in pixels of the image, and
|
||||||
// then offset by the amount of columns
|
// then offset by the amount of columns
|
||||||
let pixel = pointer[(y * width) + x]
|
let pixel = pointer[(y * width) + x]
|
||||||
|
|
||||||
let r = red(for: pixel)
|
let r = red(for: pixel)
|
||||||
let g = green(for: pixel)
|
let g = green(for: pixel)
|
||||||
let b = blue(for: pixel)
|
let b = blue(for: pixel)
|
||||||
|
|
||||||
let luminance = (0.299 * Double(r) + 0.587 * Double(g) + 0.114 * Double(b))
|
let luminance = (0.299 * Double(r) + 0.587 * Double(g) + 0.114 * Double(b))
|
||||||
|
|
||||||
totalLuminance += luminance
|
totalLuminance += luminance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let avgLuminance = totalLuminance / Double(totalPixels)
|
let avgLuminance = totalLuminance / Double(totalPixels)
|
||||||
if totalLuminance == 0 || avgLuminance < 40 {
|
if totalLuminance == 0 || avgLuminance < 40 {
|
||||||
return .dark
|
return .dark
|
||||||
@ -146,27 +146,26 @@ extension CGImage {
|
|||||||
return .regular
|
return .regular
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func red(for pixelData: UInt32) -> UInt8 {
|
private func red(for pixelData: UInt32) -> UInt8 {
|
||||||
return UInt8((pixelData >> 16) & 255)
|
return UInt8((pixelData >> 16) & 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func green(for pixelData: UInt32) -> UInt8 {
|
private func green(for pixelData: UInt32) -> UInt8 {
|
||||||
return UInt8((pixelData >> 8) & 255)
|
return UInt8((pixelData >> 8) & 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func blue(for pixelData: UInt32) -> UInt8 {
|
private func blue(for pixelData: UInt32) -> UInt8 {
|
||||||
return UInt8((pixelData >> 0) & 255)
|
return UInt8((pixelData >> 0) & 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
enum IconSize: Int, CaseIterable {
|
enum IconSize: Int, CaseIterable {
|
||||||
case small = 1
|
case small = 1
|
||||||
case medium = 2
|
case medium = 2
|
||||||
case large = 3
|
case large = 3
|
||||||
|
|
||||||
private static let smallDimension = CGFloat(integerLiteral: 24)
|
private static let smallDimension = CGFloat(integerLiteral: 24)
|
||||||
private static let mediumDimension = CGFloat(integerLiteral: 36)
|
private static let mediumDimension = CGFloat(integerLiteral: 36)
|
||||||
private static let largeDimension = CGFloat(integerLiteral: 48)
|
private static let largeDimension = CGFloat(integerLiteral: 48)
|
||||||
|
@ -40,7 +40,7 @@ extension NSAttributedString {
|
|||||||
let baseDescriptor = baseFont.fontDescriptor
|
let baseDescriptor = baseFont.fontDescriptor
|
||||||
let baseSymbolicTraits = baseDescriptor.symbolicTraits
|
let baseSymbolicTraits = baseDescriptor.symbolicTraits
|
||||||
|
|
||||||
mutable.enumerateAttribute(.font, in: fullRange, options: []) { (font: Any?, range: NSRange, stop: UnsafeMutablePointer<ObjCBool>) in
|
mutable.enumerateAttribute(.font, in: fullRange, options: []) { (font: Any?, range: NSRange, _: UnsafeMutablePointer<ObjCBool>) in
|
||||||
guard let font = font as? Font else { return }
|
guard let font = font as? Font else { return }
|
||||||
|
|
||||||
let currentDescriptor = font.fontDescriptor
|
let currentDescriptor = font.fontDescriptor
|
||||||
@ -108,7 +108,7 @@ extension NSAttributedString {
|
|||||||
public convenience init(linkText: String, linkURL: URL) {
|
public convenience init(linkText: String, linkURL: URL) {
|
||||||
let attrString = NSMutableAttributedString(string: linkText)
|
let attrString = NSMutableAttributedString(string: linkText)
|
||||||
let range = NSRange(location: 0, length: attrString.length)
|
let range = NSRange(location: 0, length: attrString.length)
|
||||||
|
|
||||||
attrString.addAttribute(.font, value: NSFont.systemFont(ofSize: NSFont.systemFontSize), range: range)
|
attrString.addAttribute(.font, value: NSFont.systemFont(ofSize: NSFont.systemFontSize), range: range)
|
||||||
attrString.addAttribute(.cursor, value: NSCursor.pointingHand, range: range)
|
attrString.addAttribute(.cursor, value: NSCursor.pointingHand, range: range)
|
||||||
attrString.addAttribute(.foregroundColor, value: NSColor.linkColor, range: range)
|
attrString.addAttribute(.foregroundColor, value: NSColor.linkColor, range: range)
|
||||||
@ -186,20 +186,19 @@ extension NSAttributedString {
|
|||||||
} else {
|
} else {
|
||||||
if char == "&" {
|
if char == "&" {
|
||||||
var entity = "&"
|
var entity = "&"
|
||||||
var lastchar: Character? = nil
|
var lastchar: Character?
|
||||||
|
|
||||||
while let entitychar = iterator.next() {
|
while let entitychar = iterator.next() {
|
||||||
if entitychar.isWhitespace {
|
if entitychar.isWhitespace {
|
||||||
lastchar = entitychar
|
lastchar = entitychar
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
entity.append(entitychar)
|
entity.append(entitychar)
|
||||||
|
|
||||||
if (entitychar == ";") { break }
|
if entitychar == ";" { break }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
result.mutableString.append(entity.decodedEntity)
|
result.mutableString.append(entity.decodedEntity)
|
||||||
|
|
||||||
if let lastchar = lastchar { result.mutableString.append(String(lastchar)) }
|
if let lastchar = lastchar { result.mutableString.append(String(lastchar)) }
|
||||||
|
@ -11,7 +11,7 @@ import AppKit
|
|||||||
extension NSView {
|
extension NSView {
|
||||||
|
|
||||||
func constraintsToMakeSubViewFullSize(_ subview: NSView) -> [NSLayoutConstraint] {
|
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 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 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 topConstraint = NSLayoutConstraint(item: subview, attribute: .top, relatedBy: .equal, toItem: self.safeAreaLayoutGuide, attribute: .top, multiplier: 1.0, constant: 0.0)
|
||||||
|
@ -27,41 +27,39 @@ extension Array where Element == Node {
|
|||||||
private extension Node {
|
private extension Node {
|
||||||
|
|
||||||
class func nodesSortedAlphabetically(_ nodes: [Node]) -> [Node] {
|
class func nodesSortedAlphabetically(_ nodes: [Node]) -> [Node] {
|
||||||
|
|
||||||
return nodes.sorted { (node1, node2) -> Bool in
|
return nodes.sorted { (node1, node2) -> Bool in
|
||||||
|
|
||||||
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let name1 = obj1.nameForDisplay
|
let name1 = obj1.nameForDisplay
|
||||||
let name2 = obj2.nameForDisplay
|
let name2 = obj2.nameForDisplay
|
||||||
|
|
||||||
return name1.localizedStandardCompare(name2) == .orderedAscending
|
return name1.localizedStandardCompare(name2) == .orderedAscending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class func nodesSortedAlphabeticallyWithFoldersAtEnd(_ nodes: [Node]) -> [Node] {
|
class func nodesSortedAlphabeticallyWithFoldersAtEnd(_ nodes: [Node]) -> [Node] {
|
||||||
|
|
||||||
return nodes.sorted { (node1, node2) -> Bool in
|
return nodes.sorted { (node1, node2) -> Bool in
|
||||||
|
|
||||||
if node1.canHaveChildNodes != node2.canHaveChildNodes {
|
if node1.canHaveChildNodes != node2.canHaveChildNodes {
|
||||||
if node1.canHaveChildNodes {
|
if node1.canHaveChildNodes {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let name1 = obj1.nameForDisplay
|
let name1 = obj1.nameForDisplay
|
||||||
let name2 = obj2.nameForDisplay
|
let name2 = obj2.nameForDisplay
|
||||||
|
|
||||||
return name1.localizedStandardCompare(name2) == .orderedAscending
|
return name1.localizedStandardCompare(name2) == .orderedAscending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,12 +13,10 @@ import AppKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
import RSCore
|
|
||||||
|
|
||||||
extension RSImage {
|
extension RSImage {
|
||||||
|
|
||||||
static let maxIconSize = 48
|
static let maxIconSize = 48
|
||||||
|
|
||||||
static func scaledForIcon(_ data: Data, imageResultBlock: @escaping ImageResultBlock) {
|
static func scaledForIcon(_ data: Data, imageResultBlock: @escaping ImageResultBlock) {
|
||||||
IconScalerQueue.shared.scaledForIcon(data, imageResultBlock)
|
IconScalerQueue.shared.scaledForIcon(data, imageResultBlock)
|
||||||
}
|
}
|
||||||
@ -34,7 +32,7 @@ extension RSImage {
|
|||||||
#else
|
#else
|
||||||
let size = NSSize(width: cgImage.width, height: cgImage.height)
|
let size = NSSize(width: cgImage.width, height: cgImage.height)
|
||||||
return RSImage(cgImage: cgImage, size: size)
|
return RSImage(cgImage: cgImage, size: size)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,12 +9,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
|
|
||||||
/// Extracts email address from a `URL` with a `mailto` scheme, otherwise `nil`.
|
/// Extracts email address from a `URL` with a `mailto` scheme, otherwise `nil`.
|
||||||
var emailAddress: String? {
|
var emailAddress: String? {
|
||||||
scheme == "mailto" ? URLComponents(url: self, resolvingAgainstBaseURL: false)?.path : nil
|
scheme == "mailto" ? URLComponents(url: self, resolvingAgainstBaseURL: false)?.path : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent encoded `mailto` URL for use with `canOpenUrl`. If the URL doesn't contain the `mailto` scheme, this is `nil`.
|
/// Percent encoded `mailto` URL for use with `canOpenUrl`. If the URL doesn't contain the `mailto` scheme, this is `nil`.
|
||||||
var percentEncodedEmailAddress: URL? {
|
var percentEncodedEmailAddress: URL? {
|
||||||
guard scheme == "mailto" else {
|
guard scheme == "mailto" else {
|
||||||
@ -25,7 +25,7 @@ extension URL {
|
|||||||
}
|
}
|
||||||
return URL(string: urlString)
|
return URL(string: urlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reverse chronological list of release notes.
|
/// Reverse chronological list of release notes.
|
||||||
static var releaseNotes = URL(string: "https://github.com/Ranchero-Software/NetNewsWire/releases/")!
|
static var releaseNotes = URL(string: "https://github.com/Ranchero-Software/NetNewsWire/releases/")!
|
||||||
|
|
||||||
@ -36,9 +36,9 @@ extension URL {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func reparingIfRequired(_ link: String?) -> URL? {
|
static func reparingIfRequired(_ link: String?) -> URL? {
|
||||||
// If required, we replace any space characters to handle malformed links that are otherwise percent
|
// If required, we replace any space characters to handle malformed links that are otherwise percent
|
||||||
// encoded but contain spaces. For performance reasons, only try this if initial URL init fails.
|
// encoded but contain spaces. For performance reasons, only try this if initial URL init fails.
|
||||||
|
@ -17,10 +17,10 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
public class ColorHash {
|
public class ColorHash {
|
||||||
|
|
||||||
public static let defaultSaturation = [CGFloat(0.35), CGFloat(0.5), CGFloat(0.65)]
|
public static let defaultSaturation = [CGFloat(0.35), CGFloat(0.5), CGFloat(0.65)]
|
||||||
public static let defaultBrightness = [CGFloat(0.5), CGFloat(0.65), CGFloat(0.80)]
|
public static let defaultBrightness = [CGFloat(0.5), CGFloat(0.65), CGFloat(0.80)]
|
||||||
|
|
||||||
let seed = CGFloat(131.0)
|
let seed = CGFloat(131.0)
|
||||||
let seed2 = CGFloat(137.0)
|
let seed2 = CGFloat(137.0)
|
||||||
let maxSafeInteger = 9007199254740991.0 / CGFloat(137.0)
|
let maxSafeInteger = 9007199254740991.0 / CGFloat(137.0)
|
||||||
@ -29,13 +29,13 @@ public class ColorHash {
|
|||||||
public private(set) var str: String
|
public private(set) var str: String
|
||||||
public private(set) var brightness: [CGFloat]
|
public private(set) var brightness: [CGFloat]
|
||||||
public private(set) var saturation: [CGFloat]
|
public private(set) var saturation: [CGFloat]
|
||||||
|
|
||||||
public init(_ str: String, _ saturation: [CGFloat] = defaultSaturation, _ brightness: [CGFloat] = defaultBrightness) {
|
public init(_ str: String, _ saturation: [CGFloat] = defaultSaturation, _ brightness: [CGFloat] = defaultBrightness) {
|
||||||
self.str = str
|
self.str = str
|
||||||
self.saturation = saturation
|
self.saturation = saturation
|
||||||
self.brightness = brightness
|
self.brightness = brightness
|
||||||
}
|
}
|
||||||
|
|
||||||
public var bkdrHash: CGFloat {
|
public var bkdrHash: CGFloat {
|
||||||
var hash = CGFloat(0)
|
var hash = CGFloat(0)
|
||||||
for char in "\(str)x" {
|
for char in "\(str)x" {
|
||||||
@ -48,7 +48,7 @@ public class ColorHash {
|
|||||||
}
|
}
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
public var HSB: (CGFloat, CGFloat, CGFloat) {
|
public var HSB: (CGFloat, CGFloat, CGFloat) {
|
||||||
var hash = CGFloat(bkdrHash)
|
var hash = CGFloat(bkdrHash)
|
||||||
let H = hash.truncatingRemainder(dividingBy: (full - 1.0)) / full
|
let H = hash.truncatingRemainder(dividingBy: (full - 1.0)) / full
|
||||||
@ -58,7 +58,7 @@ public class ColorHash {
|
|||||||
let B = brightness[Int((full * hash).truncatingRemainder(dividingBy: CGFloat(brightness.count)))]
|
let B = brightness[Int((full * hash).truncatingRemainder(dividingBy: CGFloat(brightness.count)))]
|
||||||
return (H, S, B)
|
return (H, S, B)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS) || os(watchOS)
|
#if os(iOS) || os(tvOS) || os(watchOS)
|
||||||
public var color: UIColor {
|
public var color: UIColor {
|
||||||
let (H, S, B) = HSB
|
let (H, S, B) = HSB
|
||||||
@ -70,5 +70,5 @@ public class ColorHash {
|
|||||||
return NSColor(hue: H, saturation: S, brightness: B, alpha: 1.0)
|
return NSColor(hue: H, saturation: S, brightness: B, alpha: 1.0)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ final class FaviconDownloader {
|
|||||||
private var remainingFaviconURLs = [String: ArraySlice<String>]() // homePageURL: array of faviconURLs that haven't been checked yet
|
private var remainingFaviconURLs = [String: ArraySlice<String>]() // homePageURL: array of faviconURLs that haven't been checked yet
|
||||||
private var currentHomePageHasOnlyFaviconICO = false
|
private var currentHomePageHasOnlyFaviconICO = false
|
||||||
|
|
||||||
private var homePageToFaviconURLCache = [String: String]() //homePageURL: faviconURL
|
private var homePageToFaviconURLCache = [String: String]() // homePageURL: faviconURL
|
||||||
private var homePageToFaviconURLCachePath: String
|
private var homePageToFaviconURLCachePath: String
|
||||||
private var homePageToFaviconURLCacheDirty = false {
|
private var homePageToFaviconURLCacheDirty = false {
|
||||||
didSet {
|
didSet {
|
||||||
@ -77,7 +77,7 @@ final class FaviconDownloader {
|
|||||||
func resetCache() {
|
func resetCache() {
|
||||||
cache = [Feed: IconImage]()
|
cache = [Feed: IconImage]()
|
||||||
}
|
}
|
||||||
|
|
||||||
func favicon(for feed: Feed) -> IconImage? {
|
func favicon(for feed: Feed) -> IconImage? {
|
||||||
|
|
||||||
assert(Thread.isMainThread)
|
assert(Thread.isMainThread)
|
||||||
@ -103,13 +103,13 @@ final class FaviconDownloader {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func faviconAsIcon(for feed: Feed) -> IconImage? {
|
func faviconAsIcon(for feed: Feed) -> IconImage? {
|
||||||
|
|
||||||
if let image = cache[feed] {
|
if let image = cache[feed] {
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
if let iconImage = favicon(for: feed), let imageData = iconImage.image.dataRepresentation() {
|
if let iconImage = favicon(for: feed), let imageData = iconImage.image.dataRepresentation() {
|
||||||
if let scaledImage = RSImage.scaledForIcon(imageData) {
|
if let scaledImage = RSImage.scaledForIcon(imageData) {
|
||||||
let scaledIconImage = IconImage(scaledImage)
|
let scaledIconImage = IconImage(scaledImage)
|
||||||
@ -117,7 +117,7 @@ final class FaviconDownloader {
|
|||||||
return scaledIconImage
|
return scaledIconImage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +169,7 @@ final class FaviconDownloader {
|
|||||||
self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1
|
self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1
|
||||||
|
|
||||||
if let firstIconURL = faviconURLs.first {
|
if let firstIconURL = faviconURLs.first {
|
||||||
let _ = self.favicon(with: firstIconURL, homePageURL: url)
|
_ = self.favicon(with: firstIconURL, homePageURL: url)
|
||||||
self.remainingFaviconURLs[url] = faviconURLs.dropFirst()
|
self.remainingFaviconURLs[url] = faviconURLs.dropFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,8 +196,8 @@ final class FaviconDownloader {
|
|||||||
guard let _ = singleFaviconDownloader.iconImage else {
|
guard let _ = singleFaviconDownloader.iconImage else {
|
||||||
if let faviconURLs = remainingFaviconURLs[homePageURL] {
|
if let faviconURLs = remainingFaviconURLs[homePageURL] {
|
||||||
if let nextIconURL = faviconURLs.first {
|
if let nextIconURL = faviconURLs.first {
|
||||||
let _ = favicon(with: nextIconURL, homePageURL: singleFaviconDownloader.homePageURL)
|
_ = favicon(with: nextIconURL, homePageURL: singleFaviconDownloader.homePageURL)
|
||||||
remainingFaviconURLs[homePageURL] = faviconURLs.dropFirst();
|
remainingFaviconURLs[homePageURL] = faviconURLs.dropFirst()
|
||||||
} else {
|
} else {
|
||||||
remainingFaviconURLs[homePageURL] = nil
|
remainingFaviconURLs[homePageURL] = nil
|
||||||
|
|
||||||
@ -237,7 +237,7 @@ final class FaviconDownloader {
|
|||||||
saveHomePageToFaviconURLCache()
|
saveHomePageToFaviconURLCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func saveHomePageURLsWithNoFaviconURLCacheIfNeeded() {
|
@objc func saveHomePageURLsWithNoFaviconURLCacheIfNeeded() {
|
||||||
if homePageURLsWithNoFaviconURLCacheDirty {
|
if homePageURLsWithNoFaviconURLCacheDirty {
|
||||||
saveHomePageURLsWithNoFaviconURLCache()
|
saveHomePageURLsWithNoFaviconURLCache()
|
||||||
@ -277,7 +277,7 @@ private extension FaviconDownloader {
|
|||||||
func faviconDownloader(withURL faviconURL: String, homePageURL: String?) -> SingleFaviconDownloader {
|
func faviconDownloader(withURL faviconURL: String, homePageURL: String?) -> SingleFaviconDownloader {
|
||||||
|
|
||||||
var firstTimeSeeingHomepageURL = false
|
var firstTimeSeeingHomepageURL = false
|
||||||
|
|
||||||
if let homePageURL = homePageURL, self.homePageToFaviconURLCache[homePageURL] == nil {
|
if let homePageURL = homePageURL, self.homePageToFaviconURLCache[homePageURL] == nil {
|
||||||
self.homePageToFaviconURLCache[homePageURL] = faviconURL
|
self.homePageToFaviconURLCache[homePageURL] = faviconURL
|
||||||
self.homePageToFaviconURLCacheDirty = true
|
self.homePageToFaviconURLCacheDirty = true
|
||||||
@ -358,7 +358,7 @@ private extension FaviconDownloader {
|
|||||||
assertionFailure(error.localizedDescription)
|
assertionFailure(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveHomePageURLsWithNoFaviconURLCache() {
|
func saveHomePageURLsWithNoFaviconURLCache() {
|
||||||
|
|
||||||
if Self.debugLoggingEnabled {
|
if Self.debugLoggingEnabled {
|
||||||
|
@ -15,11 +15,11 @@ final class FaviconGenerator {
|
|||||||
private static var faviconGeneratorCache = [String: IconImage]() // feedURL: RSImage
|
private static var faviconGeneratorCache = [String: IconImage]() // feedURL: RSImage
|
||||||
|
|
||||||
static func favicon(_ feed: Feed) -> IconImage {
|
static func favicon(_ feed: Feed) -> IconImage {
|
||||||
|
|
||||||
if let favicon = FaviconGenerator.faviconGeneratorCache[feed.url] {
|
if let favicon = FaviconGenerator.faviconGeneratorCache[feed.url] {
|
||||||
return favicon
|
return favicon
|
||||||
}
|
}
|
||||||
|
|
||||||
let colorHash = ColorHash(feed.url)
|
let colorHash = ColorHash(feed.url)
|
||||||
if let favicon = AppAssets.faviconTemplateImage.maskWithColor(color: colorHash.color.cgColor) {
|
if let favicon = AppAssets.faviconTemplateImage.maskWithColor(color: colorHash.color.cgColor) {
|
||||||
let iconImage = IconImage(favicon, isBackgroundSuppressed: true)
|
let iconImage = IconImage(favicon, isBackgroundSuppressed: true)
|
||||||
@ -28,7 +28,7 @@ final class FaviconGenerator {
|
|||||||
} else {
|
} else {
|
||||||
return IconImage(AppAssets.faviconTemplateImage, isBackgroundSuppressed: true)
|
return IconImage(AppAssets.faviconTemplateImage, isBackgroundSuppressed: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ final class SingleFaviconDownloader {
|
|||||||
let homePageURL: String?
|
let homePageURL: String?
|
||||||
|
|
||||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SingleFaviconDownloader")
|
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SingleFaviconDownloader")
|
||||||
|
|
||||||
private var lastDownloadAttemptDate: Date
|
private var lastDownloadAttemptDate: Date
|
||||||
private var diskStatus = DiskStatus.unknown
|
private var diskStatus = DiskStatus.unknown
|
||||||
private var diskCache: BinaryDiskCache
|
private var diskCache: BinaryDiskCache
|
||||||
@ -66,7 +66,7 @@ final class SingleFaviconDownloader {
|
|||||||
|
|
||||||
lastDownloadAttemptDate = Date()
|
lastDownloadAttemptDate = Date()
|
||||||
findFavicon()
|
findFavicon()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,7 +93,7 @@ private extension SingleFaviconDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.postDidLoadFaviconNotification()
|
self.postDidLoadFaviconNotification()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,8 +127,7 @@ private extension SingleFaviconDownloader {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.diskStatus = .onDisk
|
self.diskStatus = .onDisk
|
||||||
}
|
}
|
||||||
}
|
} catch {}
|
||||||
catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,5 +159,5 @@ private extension SingleFaviconDownloader {
|
|||||||
assert(Thread.isMainThread)
|
assert(Thread.isMainThread)
|
||||||
NotificationCenter.default.post(name: .DidLoadFavicon, object: self)
|
NotificationCenter.default.post(name: .DidLoadFavicon, object: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ class IconImageCache {
|
|||||||
guard let feedID = feed.sidebarItemID else {
|
guard let feedID = feed.sidebarItemID else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let smartFeed = feed as? PseudoFeed {
|
if let smartFeed = feed as? PseudoFeed {
|
||||||
return imageForSmartFeed(smartFeed, feedID)
|
return imageForSmartFeed(smartFeed, feedID)
|
||||||
}
|
}
|
||||||
@ -68,7 +68,7 @@ class IconImageCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension IconImageCache {
|
private extension IconImageCache {
|
||||||
|
|
||||||
func imageForSmartFeed(_ smartFeed: PseudoFeed, _ feedID: SidebarItemIdentifier) -> IconImage? {
|
func imageForSmartFeed(_ smartFeed: PseudoFeed, _ feedID: SidebarItemIdentifier) -> IconImage? {
|
||||||
if let iconImage = smartFeedIconImageCache[feedID] {
|
if let iconImage = smartFeedIconImageCache[feedID] {
|
||||||
return iconImage
|
return iconImage
|
||||||
|
@ -31,21 +31,20 @@ final class AuthorAvatarDownloader {
|
|||||||
func resetCache() {
|
func resetCache() {
|
||||||
cache = [String: IconImage]()
|
cache = [String: IconImage]()
|
||||||
}
|
}
|
||||||
|
|
||||||
func image(for author: Author) -> IconImage? {
|
func image(for author: Author) -> IconImage? {
|
||||||
|
|
||||||
guard let avatarURL = author.avatarURL else {
|
guard let avatarURL = author.avatarURL else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let cachedImage = cache[avatarURL] {
|
if let cachedImage = cache[avatarURL] {
|
||||||
return cachedImage
|
return cachedImage
|
||||||
}
|
}
|
||||||
|
|
||||||
if let imageData = imageDownloader.image(for: avatarURL) {
|
if let imageData = imageDownloader.image(for: avatarURL) {
|
||||||
scaleAndCacheImageData(imageData, avatarURL)
|
scaleAndCacheImageData(imageData, avatarURL)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
waitingForAvatarURLs.insert(avatarURL)
|
waitingForAvatarURLs.insert(avatarURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ public final class FeedIconDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkFeedIconURL() {
|
func checkFeedIconURL() {
|
||||||
if let iconURL = feed.iconURL {
|
if let iconURL = feed.iconURL {
|
||||||
icon(forURL: iconURL, feed: feed) { (image) in
|
icon(forURL: iconURL, feed: feed) { (image) in
|
||||||
|
@ -17,7 +17,7 @@ extension HTMLMetadata {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var bestImage: HTMLMetadataAppleTouchIcon? = nil
|
var bestImage: HTMLMetadataAppleTouchIcon?
|
||||||
|
|
||||||
for image in icons {
|
for image in icons {
|
||||||
if let size = image.size {
|
if let size = image.size {
|
||||||
@ -31,7 +31,7 @@ extension HTMLMetadata {
|
|||||||
}
|
}
|
||||||
if let size = image.size, let bestImageSize = bestImage!.size {
|
if let size = image.size, let bestImageSize = bestImage!.size {
|
||||||
if size.height > bestImageSize.height && size.width > bestImageSize.width {
|
if size.height > bestImageSize.height && size.width > bestImageSize.width {
|
||||||
bestImage = image;
|
bestImage = image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ extension HTMLMetadata {
|
|||||||
if let appleTouchIcon = largestAppleTouchIcon() {
|
if let appleTouchIcon = largestAppleTouchIcon() {
|
||||||
return appleTouchIcon
|
return appleTouchIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
if let openGraphImageURL = openGraphProperties?.image?.url {
|
if let openGraphImageURL = openGraphProperties?.image?.url {
|
||||||
return openGraphImageURL
|
return openGraphImageURL
|
||||||
}
|
}
|
||||||
|
@ -31,4 +31,3 @@ struct ImageUtilities {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,11 +11,10 @@ import Account
|
|||||||
import RSCore
|
import RSCore
|
||||||
|
|
||||||
struct DefaultFeedsImporter {
|
struct DefaultFeedsImporter {
|
||||||
|
|
||||||
static func importDefaultFeeds(account: Account) {
|
static func importDefaultFeeds(account: Account) {
|
||||||
let defaultFeedsURL = Bundle.main.url(forResource: "DefaultFeeds", withExtension: "opml")!
|
let defaultFeedsURL = Bundle.main.url(forResource: "DefaultFeeds", withExtension: "opml")!
|
||||||
AccountManager.shared.defaultAccount.importOPML(defaultFeedsURL) { result in }
|
AccountManager.shared.defaultAccount.importOPML(defaultFeedsURL) { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -15,13 +15,13 @@ protocol ExtensionContainer: ContainerIdentifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionContainers: Codable {
|
struct ExtensionContainers: Codable {
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case accounts
|
case accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
let accounts: [ExtensionAccount]
|
let accounts: [ExtensionAccount]
|
||||||
|
|
||||||
var flattened: [ExtensionContainer] {
|
var flattened: [ExtensionContainer] {
|
||||||
return accounts.reduce([ExtensionContainer](), { (containers, account) in
|
return accounts.reduce([ExtensionContainer](), { (containers, account) in
|
||||||
var result = containers
|
var result = containers
|
||||||
@ -30,11 +30,11 @@ struct ExtensionContainers: Codable {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func findAccount(forName name: String) -> ExtensionAccount? {
|
func findAccount(forName name: String) -> ExtensionAccount? {
|
||||||
return accounts.first(where: { $0.name == name })
|
return accounts.first(where: { $0.name == name })
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionAccount: ExtensionContainer {
|
struct ExtensionAccount: ExtensionContainer {
|
||||||
@ -67,7 +67,7 @@ struct ExtensionAccount: ExtensionContainer {
|
|||||||
func findFolder(forName name: String) -> ExtensionFolder? {
|
func findFolder(forName name: String) -> ExtensionFolder? {
|
||||||
return folders.first(where: { $0.name == name })
|
return folders.first(where: { $0.name == name })
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionFolder: ExtensionContainer {
|
struct ExtensionFolder: ExtensionContainer {
|
||||||
@ -90,5 +90,5 @@ struct ExtensionFolder: ExtensionContainer {
|
|||||||
self.name = folder.nameForDisplay
|
self.name = folder.nameForDisplay
|
||||||
self.containerID = folder.containerID
|
self.containerID = folder.containerID
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ import Parser
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
final class ExtensionContainersFile {
|
final class ExtensionContainersFile {
|
||||||
|
|
||||||
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionContainersFile")
|
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionContainersFile")
|
||||||
|
|
||||||
private static var filePath: String = {
|
private static var filePath: String = {
|
||||||
@ -21,7 +21,7 @@ final class ExtensionContainersFile {
|
|||||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
||||||
return containerURL!.appendingPathComponent("extension_containers.plist").path
|
return containerURL!.appendingPathComponent("extension_containers.plist").path
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var isDirty = false {
|
private var isDirty = false {
|
||||||
didSet {
|
didSet {
|
||||||
queueSaveToDiskIfNeeded()
|
queueSaveToDiskIfNeeded()
|
||||||
@ -33,7 +33,7 @@ final class ExtensionContainersFile {
|
|||||||
if !FileManager.default.fileExists(atPath: ExtensionContainersFile.filePath) {
|
if !FileManager.default.fileExists(atPath: ExtensionContainersFile.filePath) {
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidAddAccount, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidAddAccount, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidDeleteAccount, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidDeleteAccount, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .AccountStateDidChange, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .AccountStateDidChange, object: nil)
|
||||||
@ -45,7 +45,7 @@ final class ExtensionContainersFile {
|
|||||||
let errorPointer: NSErrorPointer = nil
|
let errorPointer: NSErrorPointer = nil
|
||||||
let fileCoordinator = NSFileCoordinator()
|
let fileCoordinator = NSFileCoordinator()
|
||||||
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
|
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
|
||||||
var extensionContainers: ExtensionContainers? = nil
|
var extensionContainers: ExtensionContainers?
|
||||||
|
|
||||||
fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in
|
fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in
|
||||||
if let fileData = try? Data(contentsOf: readURL) {
|
if let fileData = try? Data(contentsOf: readURL) {
|
||||||
@ -53,14 +53,14 @@ final class ExtensionContainersFile {
|
|||||||
extensionContainers = try? decoder.decode(ExtensionContainers.self, from: fileData)
|
extensionContainers = try? decoder.decode(ExtensionContainers.self, from: fileData)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if let error = errorPointer?.pointee {
|
if let error = errorPointer?.pointee {
|
||||||
os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription)
|
os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
return extensionContainers
|
return extensionContainers
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ExtensionContainersFile {
|
private extension ExtensionContainersFile {
|
||||||
@ -68,7 +68,7 @@ private extension ExtensionContainersFile {
|
|||||||
@objc func markAsDirty() {
|
@objc func markAsDirty() {
|
||||||
isDirty = true
|
isDirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func queueSaveToDiskIfNeeded() {
|
func queueSaveToDiskIfNeeded() {
|
||||||
saveQueue.add(self, #selector(saveToDiskIfNeeded))
|
saveQueue.add(self, #selector(saveToDiskIfNeeded))
|
||||||
}
|
}
|
||||||
@ -87,7 +87,7 @@ private extension ExtensionContainersFile {
|
|||||||
let errorPointer: NSErrorPointer = nil
|
let errorPointer: NSErrorPointer = nil
|
||||||
let fileCoordinator = NSFileCoordinator()
|
let fileCoordinator = NSFileCoordinator()
|
||||||
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
|
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
|
||||||
|
|
||||||
fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in
|
fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in
|
||||||
do {
|
do {
|
||||||
let extensionAccounts = AccountManager.shared.sortedActiveAccounts.map { ExtensionAccount(account: $0) }
|
let extensionAccounts = AccountManager.shared.sortedActiveAccounts.map { ExtensionAccount(account: $0) }
|
||||||
@ -98,7 +98,7 @@ private extension ExtensionContainersFile {
|
|||||||
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if let error = errorPointer?.pointee {
|
if let error = errorPointer?.pointee {
|
||||||
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import Foundation
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
struct ExtensionFeedAddRequest: Codable {
|
struct ExtensionFeedAddRequest: Codable {
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case feedURL
|
case feedURL
|
||||||
@ -20,5 +20,5 @@ struct ExtensionFeedAddRequest: Codable {
|
|||||||
let name: String?
|
let name: String?
|
||||||
let feedURL: URL
|
let feedURL: URL
|
||||||
let destinationContainerID: ContainerIdentifier
|
let destinationContainerID: ContainerIdentifier
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import os.log
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
||||||
|
|
||||||
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionFeedAddRequestFile")
|
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionFeedAddRequestFile")
|
||||||
|
|
||||||
private static var filePath: String = {
|
private static var filePath: String = {
|
||||||
@ -19,23 +19,23 @@ final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
|||||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
||||||
return containerURL!.appendingPathComponent("extension_feed_add_request.plist").path
|
return containerURL!.appendingPathComponent("extension_feed_add_request.plist").path
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let operationQueue: OperationQueue
|
private let operationQueue: OperationQueue
|
||||||
|
|
||||||
var presentedItemURL: URL? {
|
var presentedItemURL: URL? {
|
||||||
return URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
return URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
var presentedItemOperationQueue: OperationQueue {
|
var presentedItemOperationQueue: OperationQueue {
|
||||||
return operationQueue
|
return operationQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
operationQueue = OperationQueue()
|
operationQueue = OperationQueue()
|
||||||
operationQueue.maxConcurrentOperationCount = 1
|
operationQueue.maxConcurrentOperationCount = 1
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
NSFileCoordinator.addFilePresenter(self)
|
NSFileCoordinator.addFilePresenter(self)
|
||||||
process()
|
process()
|
||||||
}
|
}
|
||||||
@ -50,13 +50,13 @@ final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
|||||||
NSFileCoordinator.addFilePresenter(self)
|
NSFileCoordinator.addFilePresenter(self)
|
||||||
process()
|
process()
|
||||||
}
|
}
|
||||||
|
|
||||||
func suspend() {
|
func suspend() {
|
||||||
NSFileCoordinator.removeFilePresenter(self)
|
NSFileCoordinator.removeFilePresenter(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ feedAddRequest: ExtensionFeedAddRequest) {
|
static func save(_ feedAddRequest: ExtensionFeedAddRequest) {
|
||||||
|
|
||||||
let decoder = PropertyListDecoder()
|
let decoder = PropertyListDecoder()
|
||||||
let encoder = PropertyListEncoder()
|
let encoder = PropertyListEncoder()
|
||||||
encoder.outputFormat = .binary
|
encoder.outputFormat = .binary
|
||||||
@ -64,10 +64,10 @@ final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
|||||||
let errorPointer: NSErrorPointer = nil
|
let errorPointer: NSErrorPointer = nil
|
||||||
let fileCoordinator = NSFileCoordinator()
|
let fileCoordinator = NSFileCoordinator()
|
||||||
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
||||||
|
|
||||||
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
|
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
|
||||||
do {
|
do {
|
||||||
|
|
||||||
var requests: [ExtensionFeedAddRequest]
|
var requests: [ExtensionFeedAddRequest]
|
||||||
if let fileData = try? Data(contentsOf: url),
|
if let fileData = try? Data(contentsOf: url),
|
||||||
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
|
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
|
||||||
@ -75,28 +75,28 @@ final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
|
|||||||
} else {
|
} else {
|
||||||
requests = [ExtensionFeedAddRequest]()
|
requests = [ExtensionFeedAddRequest]()
|
||||||
}
|
}
|
||||||
|
|
||||||
requests.append(feedAddRequest)
|
requests.append(feedAddRequest)
|
||||||
|
|
||||||
let data = try encoder.encode(requests)
|
let data = try encoder.encode(requests)
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
|
|
||||||
} catch let error as NSError {
|
} catch let error as NSError {
|
||||||
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if let error = errorPointer?.pointee {
|
if let error = errorPointer?.pointee {
|
||||||
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ExtensionFeedAddRequestFile {
|
private extension ExtensionFeedAddRequestFile {
|
||||||
|
|
||||||
func process() {
|
func process() {
|
||||||
|
|
||||||
let decoder = PropertyListDecoder()
|
let decoder = PropertyListDecoder()
|
||||||
let encoder = PropertyListEncoder()
|
let encoder = PropertyListEncoder()
|
||||||
encoder.outputFormat = .binary
|
encoder.outputFormat = .binary
|
||||||
@ -105,24 +105,24 @@ private extension ExtensionFeedAddRequestFile {
|
|||||||
let fileCoordinator = NSFileCoordinator(filePresenter: self)
|
let fileCoordinator = NSFileCoordinator(filePresenter: self)
|
||||||
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
|
||||||
|
|
||||||
var requests: [ExtensionFeedAddRequest]? = nil
|
var requests: [ExtensionFeedAddRequest]?
|
||||||
|
|
||||||
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
|
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
|
||||||
do {
|
do {
|
||||||
|
|
||||||
if let fileData = try? Data(contentsOf: url),
|
if let fileData = try? Data(contentsOf: url),
|
||||||
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
|
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
|
||||||
requests = decodedRequests
|
requests = decodedRequests
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = try encoder.encode([ExtensionFeedAddRequest]())
|
let data = try encoder.encode([ExtensionFeedAddRequest]())
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
|
|
||||||
} catch let error as NSError {
|
} catch let error as NSError {
|
||||||
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if let error = errorPointer?.pointee {
|
if let error = errorPointer?.pointee {
|
||||||
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
|
||||||
}
|
}
|
||||||
@ -133,9 +133,9 @@ private extension ExtensionFeedAddRequestFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processRequest(_ request: ExtensionFeedAddRequest) {
|
func processRequest(_ request: ExtensionFeedAddRequest) {
|
||||||
var destinationAccountID: String? = nil
|
var destinationAccountID: String?
|
||||||
switch request.destinationContainerID {
|
switch request.destinationContainerID {
|
||||||
case .account(let accountID):
|
case .account(let accountID):
|
||||||
destinationAccountID = accountID
|
destinationAccountID = accountID
|
||||||
@ -144,21 +144,21 @@ private extension ExtensionFeedAddRequestFile {
|
|||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let accountID = destinationAccountID, let account = AccountManager.shared.existingAccount(with: accountID) else {
|
guard let accountID = destinationAccountID, let account = AccountManager.shared.existingAccount(with: accountID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var destinationContainer: Container? = nil
|
var destinationContainer: Container?
|
||||||
if account.containerID == request.destinationContainerID {
|
if account.containerID == request.destinationContainerID {
|
||||||
destinationContainer = account
|
destinationContainer = account
|
||||||
} else {
|
} else {
|
||||||
destinationContainer = account.folders?.first(where: { $0.containerID == request.destinationContainerID })
|
destinationContainer = account.folders?.first(where: { $0.containerID == request.destinationContainerID })
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let container = destinationContainer else { return }
|
guard let container = destinationContainer else { return }
|
||||||
|
|
||||||
account.createFeed(url: request.feedURL.absoluteString, name: request.name, container: container, validateFeed: true) { _ in }
|
account.createFeed(url: request.feedURL.absoluteString, name: request.name, container: container, validateFeed: true) { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -9,9 +9,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ShareDefaultContainer {
|
struct ShareDefaultContainer {
|
||||||
|
|
||||||
static func defaultContainer(containers: ExtensionContainers) -> ExtensionContainer? {
|
static func defaultContainer(containers: ExtensionContainers) -> ExtensionContainer? {
|
||||||
|
|
||||||
if let accountID = AppDefaults.shared.addFeedAccountID, let account = containers.accounts.first(where: { $0.accountID == accountID }) {
|
if let accountID = AppDefaults.shared.addFeedAccountID, let account = containers.accounts.first(where: { $0.accountID == accountID }) {
|
||||||
if let folderName = AppDefaults.shared.addFeedFolderName, let folder = account.folders.first(where: { $0.name == folderName }) {
|
if let folderName = AppDefaults.shared.addFeedFolderName, let folder = account.folders.first(where: { $0.name == folderName }) {
|
||||||
return folder
|
return folder
|
||||||
@ -23,9 +23,9 @@ struct ShareDefaultContainer {
|
|||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveDefaultContainer(_ container: ExtensionContainer) {
|
static func saveDefaultContainer(_ container: ExtensionContainer) {
|
||||||
AppDefaults.shared.addFeedAccountID = container.accountID
|
AppDefaults.shared.addFeedAccountID = container.accountID
|
||||||
if let folder = container as? ExtensionFolder {
|
if let folder = container as? ExtensionFolder {
|
||||||
@ -34,7 +34,7 @@ struct ShareDefaultContainer {
|
|||||||
AppDefaults.shared.addFeedFolderName = nil
|
AppDefaults.shared.addFeedFolderName = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func substituteContainerIfNeeded(account: ExtensionAccount) -> ExtensionContainer? {
|
private static func substituteContainerIfNeeded(account: ExtensionAccount) -> ExtensionContainer? {
|
||||||
if !account.disallowFeedInRootFolder {
|
if !account.disallowFeedInRootFolder {
|
||||||
return account
|
return account
|
||||||
|
@ -25,7 +25,7 @@ import Account
|
|||||||
import RSCore
|
import RSCore
|
||||||
|
|
||||||
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider {
|
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
@ -36,4 +36,3 @@ struct SearchFeedDelegate: SmartFeedDelegate {
|
|||||||
// TODO: after 5.0
|
// TODO: after 5.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ import Account
|
|||||||
|
|
||||||
final class SmartFeed: PseudoFeed {
|
final class SmartFeed: PseudoFeed {
|
||||||
|
|
||||||
var account: Account? = nil
|
var account: Account?
|
||||||
|
|
||||||
public var defaultReadFilterType: ReadFilterType {
|
public var defaultReadFilterType: ReadFilterType {
|
||||||
return .none
|
return .none
|
||||||
@ -39,7 +39,7 @@ final class SmartFeed: PseudoFeed {
|
|||||||
var smallIcon: IconImage? {
|
var smallIcon: IconImage? {
|
||||||
return delegate.smallIcon
|
return delegate.smallIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
var pasteboardWriter: NSPasteboardWriting {
|
var pasteboardWriter: NSPasteboardWriting {
|
||||||
return SmartFeedPasteboardWriter(smartFeed: self)
|
return SmartFeedPasteboardWriter(smartFeed: self)
|
||||||
@ -63,7 +63,7 @@ final class SmartFeed: PseudoFeed {
|
|||||||
|
|
||||||
@objc func fetchUnreadCounts() {
|
@objc func fetchUnreadCounts() {
|
||||||
let activeAccounts = AccountManager.shared.activeAccounts
|
let activeAccounts = AccountManager.shared.activeAccounts
|
||||||
|
|
||||||
// Remove any accounts that are no longer active or have been deleted
|
// Remove any accounts that are no longer active or have been deleted
|
||||||
let activeAccountIDs = activeAccounts.map { $0.accountID }
|
let activeAccountIDs = activeAccounts.map { $0.accountID }
|
||||||
for accountID in unreadCounts.keys {
|
for accountID in unreadCounts.keys {
|
||||||
@ -71,7 +71,7 @@ final class SmartFeed: PseudoFeed {
|
|||||||
unreadCounts.removeValue(forKey: accountID)
|
unreadCounts.removeValue(forKey: accountID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if activeAccounts.isEmpty {
|
if activeAccounts.isEmpty {
|
||||||
updateUnreadCount()
|
updateUnreadCount()
|
||||||
} else {
|
} else {
|
||||||
@ -80,7 +80,7 @@ final class SmartFeed: PseudoFeed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SmartFeed: ArticleFetcher {
|
extension SmartFeed: ArticleFetcher {
|
||||||
|
@ -32,7 +32,7 @@ extension SmartFeedDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
||||||
fetchArticlesAsync{ articleSetResult in
|
fetchArticlesAsync { articleSetResult in
|
||||||
switch articleSetResult {
|
switch articleSetResult {
|
||||||
case .success(let articles):
|
case .success(let articles):
|
||||||
completion(.success(articles.unreadArticles()))
|
completion(.success(articles.unreadArticles()))
|
||||||
|
@ -40,4 +40,3 @@ import RSCore
|
|||||||
return plist
|
return plist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ import RSCore
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable {
|
final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable {
|
||||||
|
|
||||||
var containerID: ContainerIdentifier? {
|
var containerID: ContainerIdentifier? {
|
||||||
return ContainerIdentifier.smartFeedController
|
return ContainerIdentifier.smartFeedController
|
||||||
}
|
}
|
||||||
@ -27,7 +27,7 @@ final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable {
|
|||||||
private init() {
|
private init() {
|
||||||
self.smartFeeds = [todayFeed, unreadFeed, starredFeed]
|
self.smartFeeds = [todayFeed, unreadFeed, starredFeed]
|
||||||
}
|
}
|
||||||
|
|
||||||
func find(by identifier: SidebarItemIdentifier) -> PseudoFeed? {
|
func find(by identifier: SidebarItemIdentifier) -> PseudoFeed? {
|
||||||
switch identifier {
|
switch identifier {
|
||||||
case .smartFeed(let stringIdentifer):
|
case .smartFeed(let stringIdentifer):
|
||||||
@ -45,5 +45,5 @@ final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -17,15 +17,14 @@ struct TodayFeedDelegate: SmartFeedDelegate {
|
|||||||
var sidebarItemID: SidebarItemIdentifier? {
|
var sidebarItemID: SidebarItemIdentifier? {
|
||||||
return SidebarItemIdentifier.smartFeed(String(describing: TodayFeedDelegate.self))
|
return SidebarItemIdentifier.smartFeed(String(describing: TodayFeedDelegate.self))
|
||||||
}
|
}
|
||||||
|
|
||||||
let nameForDisplay = NSLocalizedString("Today", comment: "Today pseudo-feed title")
|
let nameForDisplay = NSLocalizedString("Today", comment: "Today pseudo-feed title")
|
||||||
let fetchType = FetchType.today(nil)
|
let fetchType = FetchType.today(nil)
|
||||||
var smallIcon: IconImage? {
|
var smallIcon: IconImage? {
|
||||||
return AppAssets.todayFeedImage
|
return AppAssets.todayFeedImage
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUnreadCount(for account: Account, completion: @escaping SingleUnreadCountCompletionBlock) {
|
func fetchUnreadCount(for account: Account, completion: @escaping SingleUnreadCountCompletionBlock) {
|
||||||
account.fetchUnreadCountForToday(completion)
|
account.fetchUnreadCountForToday(completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,8 +19,8 @@ import ArticlesDatabase
|
|||||||
// This just shows the global unread count, which appDelegate already has. Easy.
|
// This just shows the global unread count, which appDelegate already has. Easy.
|
||||||
|
|
||||||
final class UnreadFeed: PseudoFeed {
|
final class UnreadFeed: PseudoFeed {
|
||||||
|
|
||||||
var account: Account? = nil
|
var account: Account?
|
||||||
|
|
||||||
public var defaultReadFilterType: ReadFilterType {
|
public var defaultReadFilterType: ReadFilterType {
|
||||||
return .alwaysRead
|
return .alwaysRead
|
||||||
@ -32,7 +32,7 @@ final class UnreadFeed: PseudoFeed {
|
|||||||
|
|
||||||
let nameForDisplay = NSLocalizedString("All Unread", comment: "All Unread pseudo-feed title")
|
let nameForDisplay = NSLocalizedString("All Unread", comment: "All Unread pseudo-feed title")
|
||||||
let fetchType = FetchType.unread(nil)
|
let fetchType = FetchType.unread(nil)
|
||||||
|
|
||||||
var unreadCount = 0 {
|
var unreadCount = 0 {
|
||||||
didSet {
|
didSet {
|
||||||
if unreadCount != oldValue {
|
if unreadCount != oldValue {
|
||||||
@ -44,13 +44,13 @@ final class UnreadFeed: PseudoFeed {
|
|||||||
var smallIcon: IconImage? {
|
var smallIcon: IconImage? {
|
||||||
return AppAssets.unreadFeedImage
|
return AppAssets.unreadFeedImage
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
var pasteboardWriter: NSPasteboardWriting {
|
var pasteboardWriter: NSPasteboardWriting {
|
||||||
return SmartFeedPasteboardWriter(smartFeed: self)
|
return SmartFeedPasteboardWriter(smartFeed: self)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
||||||
self.unreadCount = appDelegate.unreadCount
|
self.unreadCount = appDelegate.unreadCount
|
||||||
@ -65,7 +65,7 @@ final class UnreadFeed: PseudoFeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension UnreadFeed: ArticleFetcher {
|
extension UnreadFeed: ArticleFetcher {
|
||||||
|
|
||||||
func fetchArticles() throws -> Set<Article> {
|
func fetchArticles() throws -> Set<Article> {
|
||||||
return try fetchUnreadArticles()
|
return try fetchUnreadArticles()
|
||||||
}
|
}
|
||||||
|
@ -23,10 +23,10 @@ extension Array where Element == Article {
|
|||||||
func orderedRowIndexes(fromIndex startIndex: Int, wrappingToTop wrapping: Bool) -> [Int] {
|
func orderedRowIndexes(fromIndex startIndex: Int, wrappingToTop wrapping: Bool) -> [Int] {
|
||||||
if startIndex >= self.count {
|
if startIndex >= self.count {
|
||||||
// Wrap around to the top if specified
|
// Wrap around to the top if specified
|
||||||
return wrapping ? Array<Int>(0..<self.count) : []
|
return wrapping ? [Int](0..<self.count) : []
|
||||||
} else {
|
} else {
|
||||||
// Start at the selection and wrap around to the beginning
|
// Start at the selection and wrap around to the beginning
|
||||||
return Array<Int>(startIndex..<self.count) + (wrapping ? Array<Int>(0..<startIndex) : [])
|
return [Int](startIndex..<self.count) + (wrapping ? [Int](0..<startIndex) : [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func rowOfNextUnreadArticle(_ selectedRow: Int, wrappingToTop wrapping: Bool) -> Int? {
|
func rowOfNextUnreadArticle(_ selectedRow: Int, wrappingToTop wrapping: Bool) -> Int? {
|
||||||
@ -45,15 +45,15 @@ extension Array where Element == Article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func articlesForIndexes(_ indexes: IndexSet) -> Set<Article> {
|
func articlesForIndexes(_ indexes: IndexSet) -> Set<Article> {
|
||||||
return Set(indexes.compactMap{ (oneIndex) -> Article? in
|
return Set(indexes.compactMap { (oneIndex) -> Article? in
|
||||||
return articleAtRow(oneIndex)
|
return articleAtRow(oneIndex)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedByDate(_ sortDirection: ComparisonResult, groupByFeed: Bool = false) -> ArticleArray {
|
func sortedByDate(_ sortDirection: ComparisonResult, groupByFeed: Bool = false) -> ArticleArray {
|
||||||
return ArticleSorter.sortedByDate(articles: self, sortDirection: sortDirection, groupByFeed: groupByFeed)
|
return ArticleSorter.sortedByDate(articles: self, sortDirection: sortDirection, groupByFeed: groupByFeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canMarkAllAsRead() -> Bool {
|
func canMarkAllAsRead() -> Bool {
|
||||||
return anyArticleIsUnread()
|
return anyArticleIsUnread()
|
||||||
}
|
}
|
||||||
@ -84,7 +84,7 @@ extension Array where Element == Article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func unreadArticles() -> [Article]? {
|
func unreadArticles() -> [Article]? {
|
||||||
let articles = self.filter{ !$0.status.read }
|
let articles = self.filter { !$0.status.read }
|
||||||
return articles.isEmpty ? nil : articles
|
return articles.isEmpty ? nil : articles
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ extension Array where Element == Article {
|
|||||||
guard let position = firstIndex(of: article) else { return [] }
|
guard let position = firstIndex(of: article) else { return [] }
|
||||||
return articlesAbove(position: position)
|
return articlesAbove(position: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func articlesAbove(position: Int) -> [Article] {
|
func articlesAbove(position: Int) -> [Article] {
|
||||||
guard position < count else { return [] }
|
guard position < count else { return [] }
|
||||||
let articlesAbove = self[..<position]
|
let articlesAbove = self[..<position]
|
||||||
@ -118,7 +118,7 @@ extension Array where Element == Article {
|
|||||||
guard let position = firstIndex(of: article) else { return [] }
|
guard let position = firstIndex(of: article) else { return [] }
|
||||||
return articlesBelow(position: position)
|
return articlesBelow(position: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func articlesBelow(position: Int) -> [Article] {
|
func articlesBelow(position: Int) -> [Article] {
|
||||||
guard position < count else { return [] }
|
guard position < count else { return [] }
|
||||||
var articlesBelow = Array(self[position...])
|
var articlesBelow = Array(self[position...])
|
||||||
@ -128,6 +128,5 @@ extension Array where Element == Article {
|
|||||||
articlesBelow.removeFirst()
|
articlesBelow.removeFirst()
|
||||||
return articlesBelow
|
return articlesBelow
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -17,7 +17,7 @@ protocol SortableArticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ArticleSorter {
|
struct ArticleSorter {
|
||||||
|
|
||||||
static func sortedByDate<T: SortableArticle>(articles: [T],
|
static func sortedByDate<T: SortableArticle>(articles: [T],
|
||||||
sortDirection: ComparisonResult,
|
sortDirection: ComparisonResult,
|
||||||
groupByFeed: Bool) -> [T] {
|
groupByFeed: Bool) -> [T] {
|
||||||
@ -27,9 +27,9 @@ struct ArticleSorter {
|
|||||||
return sortedByDate(articles: articles, sortDirection: sortDirection)
|
return sortedByDate(articles: articles, sortDirection: sortDirection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
private static func sortedByFeedName<T: SortableArticle>(articles: [T],
|
private static func sortedByFeedName<T: SortableArticle>(articles: [T],
|
||||||
sortByDateDirection: ComparisonResult) -> [T] {
|
sortByDateDirection: ComparisonResult) -> [T] {
|
||||||
// Group articles by "feed-feedID" - feed ID is used to differentiate between
|
// Group articles by "feed-feedID" - feed ID is used to differentiate between
|
||||||
@ -39,11 +39,11 @@ struct ArticleSorter {
|
|||||||
.sorted { $0.key < $1.key }
|
.sorted { $0.key < $1.key }
|
||||||
.flatMap { (tuple) -> [T] in
|
.flatMap { (tuple) -> [T] in
|
||||||
let (_, articles) = tuple
|
let (_, articles) = tuple
|
||||||
|
|
||||||
return sortedByDate(articles: articles, sortDirection: sortByDateDirection)
|
return sortedByDate(articles: articles, sortDirection: sortByDateDirection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sortedByDate<T: SortableArticle>(articles: [T],
|
private static func sortedByDate<T: SortableArticle>(articles: [T],
|
||||||
sortDirection: ComparisonResult) -> [T] {
|
sortDirection: ComparisonResult) -> [T] {
|
||||||
return articles.sorted { (article1, article2) -> Bool in
|
return articles.sorted { (article1, article2) -> Bool in
|
||||||
@ -53,9 +53,9 @@ struct ArticleSorter {
|
|||||||
if sortDirection == .orderedDescending {
|
if sortDirection == .orderedDescending {
|
||||||
return article1.sortableDate > article2.sortableDate
|
return article1.sortableDate > article2.sortableDate
|
||||||
}
|
}
|
||||||
|
|
||||||
return article1.sortableDate < article2.sortableDate
|
return article1.sortableDate < article2.sortableDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -61,14 +61,14 @@ final class FetchRequestOperation {
|
|||||||
let numberOfFetchers = fetchers.count
|
let numberOfFetchers = fetchers.count
|
||||||
var fetchersReturned = 0
|
var fetchersReturned = 0
|
||||||
var fetchedArticles = Set<Article>()
|
var fetchedArticles = Set<Article>()
|
||||||
|
|
||||||
func process(_ articles: Set<Article>) {
|
func process(_ articles: Set<Article>) {
|
||||||
precondition(Thread.isMainThread)
|
precondition(Thread.isMainThread)
|
||||||
guard !self.isCanceled else {
|
guard !self.isCanceled else {
|
||||||
callCompletionIfNeeded()
|
callCompletionIfNeeded()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(!self.isFinished)
|
assert(!self.isFinished)
|
||||||
|
|
||||||
fetchedArticles.formUnion(articles)
|
fetchedArticles.formUnion(articles)
|
||||||
@ -79,7 +79,7 @@ final class FetchRequestOperation {
|
|||||||
callCompletionIfNeeded()
|
callCompletionIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for fetcher in fetchers {
|
for fetcher in fetchers {
|
||||||
if (fetcher as? SidebarItem)?.readFiltered(readFilterEnabledTable: readFilterEnabledTable) ?? true {
|
if (fetcher as? SidebarItem)?.readFiltered(readFilterEnabledTable: readFilterEnabledTable) ?? true {
|
||||||
fetcher.fetchUnreadArticlesAsync { articleSetResult in
|
fetcher.fetchUnreadArticlesAsync { articleSetResult in
|
||||||
@ -92,8 +92,7 @@ final class FetchRequestOperation {
|
|||||||
process(articles)
|
process(articles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,8 +13,8 @@ import Foundation
|
|||||||
final class FetchRequestQueue {
|
final class FetchRequestQueue {
|
||||||
|
|
||||||
private var pendingRequests = [FetchRequestOperation]()
|
private var pendingRequests = [FetchRequestOperation]()
|
||||||
private var currentRequest: FetchRequestOperation? = nil
|
private var currentRequest: FetchRequestOperation?
|
||||||
|
|
||||||
var isAnyCurrentRequest: Bool {
|
var isAnyCurrentRequest: Bool {
|
||||||
if let currentRequest = currentRequest {
|
if let currentRequest = currentRequest {
|
||||||
return !currentRequest.isCanceled
|
return !currentRequest.isCanceled
|
||||||
@ -57,6 +57,6 @@ private extension FetchRequestQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeCanceledAndFinishedRequests() {
|
func removeCanceledAndFinishedRequests() {
|
||||||
pendingRequests = pendingRequests.filter{ !$0.isCanceled && !$0.isFinished }
|
pendingRequests = pendingRequests.filter { !$0.isCanceled && !$0.isFinished }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,13 +10,13 @@ import Foundation
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
final class AccountRefreshTimer {
|
final class AccountRefreshTimer {
|
||||||
|
|
||||||
var shuttingDown = false
|
var shuttingDown = false
|
||||||
|
|
||||||
private var internalTimer: Timer?
|
private var internalTimer: Timer?
|
||||||
private var lastTimedRefresh: Date?
|
private var lastTimedRefresh: Date?
|
||||||
private let launchTime = Date()
|
private let launchTime = Date()
|
||||||
|
|
||||||
func fireOldTimer() {
|
func fireOldTimer() {
|
||||||
if let timer = internalTimer {
|
if let timer = internalTimer {
|
||||||
if timer.fireDate < Date() {
|
if timer.fireDate < Date() {
|
||||||
@ -26,7 +26,7 @@ final class AccountRefreshTimer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidate() {
|
func invalidate() {
|
||||||
guard let timer = internalTimer else {
|
guard let timer = internalTimer else {
|
||||||
return
|
return
|
||||||
@ -36,12 +36,12 @@ final class AccountRefreshTimer {
|
|||||||
}
|
}
|
||||||
internalTimer = nil
|
internalTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func update() {
|
func update() {
|
||||||
guard !shuttingDown else {
|
guard !shuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshInterval = AppDefaults.shared.refreshInterval
|
let refreshInterval = AppDefaults.shared.refreshInterval
|
||||||
if refreshInterval == .manually {
|
if refreshInterval == .manually {
|
||||||
invalidate()
|
invalidate()
|
||||||
@ -56,23 +56,23 @@ final class AccountRefreshTimer {
|
|||||||
if let currentNextFireDate = internalTimer?.fireDate, currentNextFireDate == nextRefreshTime {
|
if let currentNextFireDate = internalTimer?.fireDate, currentNextFireDate == nextRefreshTime {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate()
|
invalidate()
|
||||||
let timer = Timer(fireAt: nextRefreshTime, interval: 0, target: self, selector: #selector(timedRefresh(_:)), userInfo: nil, repeats: false)
|
let timer = Timer(fireAt: nextRefreshTime, interval: 0, target: self, selector: #selector(timedRefresh(_:)), userInfo: nil, repeats: false)
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
internalTimer = timer
|
internalTimer = timer
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func timedRefresh(_ sender: Timer?) {
|
@objc func timedRefresh(_ sender: Timer?) {
|
||||||
|
|
||||||
guard !shuttingDown else {
|
guard !shuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTimedRefresh = Date()
|
lastTimedRefresh = Date()
|
||||||
update()
|
update()
|
||||||
|
|
||||||
AccountManager.shared.refreshAll()
|
AccountManager.shared.refreshAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,15 +10,15 @@ import Foundation
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
class ArticleStatusSyncTimer {
|
class ArticleStatusSyncTimer {
|
||||||
|
|
||||||
private static let intervalSeconds = Double(120)
|
private static let intervalSeconds = Double(120)
|
||||||
|
|
||||||
var shuttingDown = false
|
var shuttingDown = false
|
||||||
|
|
||||||
private var internalTimer: Timer?
|
private var internalTimer: Timer?
|
||||||
private var lastTimedRefresh: Date?
|
private var lastTimedRefresh: Date?
|
||||||
private let launchTime = Date()
|
private let launchTime = Date()
|
||||||
|
|
||||||
func fireOldTimer() {
|
func fireOldTimer() {
|
||||||
if let timer = internalTimer {
|
if let timer = internalTimer {
|
||||||
if timer.fireDate < Date() {
|
if timer.fireDate < Date() {
|
||||||
@ -26,7 +26,7 @@ class ArticleStatusSyncTimer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidate() {
|
func invalidate() {
|
||||||
guard let timer = internalTimer else {
|
guard let timer = internalTimer else {
|
||||||
return
|
return
|
||||||
@ -36,13 +36,13 @@ class ArticleStatusSyncTimer {
|
|||||||
}
|
}
|
||||||
internalTimer = nil
|
internalTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func update() {
|
func update() {
|
||||||
|
|
||||||
guard !shuttingDown else {
|
guard !shuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastRefreshDate = lastTimedRefresh ?? launchTime
|
let lastRefreshDate = lastTimedRefresh ?? launchTime
|
||||||
var nextRefreshTime = lastRefreshDate.addingTimeInterval(ArticleStatusSyncTimer.intervalSeconds)
|
var nextRefreshTime = lastRefreshDate.addingTimeInterval(ArticleStatusSyncTimer.intervalSeconds)
|
||||||
if nextRefreshTime < Date() {
|
if nextRefreshTime < Date() {
|
||||||
@ -51,25 +51,25 @@ class ArticleStatusSyncTimer {
|
|||||||
if let currentNextFireDate = internalTimer?.fireDate, currentNextFireDate == nextRefreshTime {
|
if let currentNextFireDate = internalTimer?.fireDate, currentNextFireDate == nextRefreshTime {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate()
|
invalidate()
|
||||||
let timer = Timer(fireAt: nextRefreshTime, interval: 0, target: self, selector: #selector(timedRefresh(_:)), userInfo: nil, repeats: false)
|
let timer = Timer(fireAt: nextRefreshTime, interval: 0, target: self, selector: #selector(timedRefresh(_:)), userInfo: nil, repeats: false)
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
internalTimer = timer
|
internalTimer = timer
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func timedRefresh(_ sender: Timer?) {
|
@objc func timedRefresh(_ sender: Timer?) {
|
||||||
|
|
||||||
guard !shuttingDown else {
|
guard !shuttingDown else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTimedRefresh = Date()
|
lastTimedRefresh = Date()
|
||||||
update()
|
update()
|
||||||
|
|
||||||
AccountManager.shared.syncArticleStatusAll()
|
AccountManager.shared.syncArticleStatusAll()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ enum RefreshInterval: Int, CaseIterable, Identifiable {
|
|||||||
case every2Hours = 5
|
case every2Hours = 5
|
||||||
case every4Hours = 6
|
case every4Hours = 6
|
||||||
case every8Hours = 7
|
case every8Hours = 7
|
||||||
|
|
||||||
func inSeconds() -> TimeInterval {
|
func inSeconds() -> TimeInterval {
|
||||||
switch self {
|
switch self {
|
||||||
case .manually:
|
case .manually:
|
||||||
@ -35,9 +35,9 @@ enum RefreshInterval: Int, CaseIterable, Identifiable {
|
|||||||
return 8 * 60 * 60
|
return 8 * 60 * 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var id: String { description() }
|
var id: String { description() }
|
||||||
|
|
||||||
func description() -> String {
|
func description() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .manually:
|
case .manually:
|
||||||
@ -56,5 +56,5 @@ enum RefreshInterval: Int, CaseIterable, Identifiable {
|
|||||||
return NSLocalizedString("Every 8 Hours", comment: "Every 8 Hours")
|
return NSLocalizedString("Every 8 Hours", comment: "Every 8 Hours")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -15,15 +15,15 @@ final class FeedTreeControllerDelegate: TreeControllerDelegate {
|
|||||||
|
|
||||||
private var filterExceptions = Set<SidebarItemIdentifier>()
|
private var filterExceptions = Set<SidebarItemIdentifier>()
|
||||||
var isReadFiltered = false
|
var isReadFiltered = false
|
||||||
|
|
||||||
func addFilterException(_ feedID: SidebarItemIdentifier) {
|
func addFilterException(_ feedID: SidebarItemIdentifier) {
|
||||||
filterExceptions.insert(feedID)
|
filterExceptions.insert(feedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetFilterExceptions() {
|
func resetFilterExceptions() {
|
||||||
filterExceptions = Set<SidebarItemIdentifier>()
|
filterExceptions = Set<SidebarItemIdentifier>()
|
||||||
}
|
}
|
||||||
|
|
||||||
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
||||||
if node.isRoot {
|
if node.isRoot {
|
||||||
return childNodesForRootNode(node)
|
return childNodesForRootNode(node)
|
||||||
@ -36,11 +36,11 @@ final class FeedTreeControllerDelegate: TreeControllerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension FeedTreeControllerDelegate {
|
private extension FeedTreeControllerDelegate {
|
||||||
|
|
||||||
func childNodesForRootNode(_ rootNode: Node) -> [Node]? {
|
func childNodesForRootNode(_ rootNode: Node) -> [Node]? {
|
||||||
var topLevelNodes = [Node]()
|
var topLevelNodes = [Node]()
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ private extension FeedTreeControllerDelegate {
|
|||||||
topLevelNodes.append(smartFeedsNode)
|
topLevelNodes.append(smartFeedsNode)
|
||||||
|
|
||||||
topLevelNodes.append(contentsOf: sortedAccountNodes(rootNode))
|
topLevelNodes.append(contentsOf: sortedAccountNodes(rootNode))
|
||||||
|
|
||||||
return topLevelNodes
|
return topLevelNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,13 +65,13 @@ private extension FeedTreeControllerDelegate {
|
|||||||
let container = containerNode.representedObject as! Container
|
let container = containerNode.representedObject as! Container
|
||||||
|
|
||||||
var children = [AnyObject]()
|
var children = [AnyObject]()
|
||||||
|
|
||||||
for feed in container.topLevelFeeds {
|
for feed in container.topLevelFeeds {
|
||||||
if let feedID = feed.sidebarItemID, !(!filterExceptions.contains(feedID) && isReadFiltered && feed.unreadCount == 0) {
|
if let feedID = feed.sidebarItemID, !(!filterExceptions.contains(feedID) && isReadFiltered && feed.unreadCount == 0) {
|
||||||
children.append(feed)
|
children.append(feed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let folders = container.folders {
|
if let folders = container.folders {
|
||||||
for folder in folders {
|
for folder in folders {
|
||||||
if let feedID = folder.sidebarItemID, !(!filterExceptions.contains(feedID) && isReadFiltered && folder.unreadCount == 0) {
|
if let feedID = folder.sidebarItemID, !(!filterExceptions.contains(feedID) && isReadFiltered && folder.unreadCount == 0) {
|
||||||
@ -107,18 +107,18 @@ private extension FeedTreeControllerDelegate {
|
|||||||
if let folder = representedObject as? Folder {
|
if let folder = representedObject as? Folder {
|
||||||
return createNode(folder: folder, parent: parent)
|
return createNode(folder: folder, parent: parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let account = representedObject as? Account {
|
if let account = representedObject as? Account {
|
||||||
return createNode(account: account, parent: parent)
|
return createNode(account: account, parent: parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNode(feed: Feed, parent: Node) -> Node {
|
func createNode(feed: Feed, parent: Node) -> Node {
|
||||||
return parent.createChildNode(feed)
|
return parent.createChildNode(feed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNode(folder: Folder, parent: Node) -> Node {
|
func createNode(folder: Folder, parent: Node) -> Node {
|
||||||
let node = parent.createChildNode(folder)
|
let node = parent.createChildNode(folder)
|
||||||
node.canHaveChildNodes = true
|
node.canHaveChildNodes = true
|
||||||
|
@ -13,7 +13,7 @@ import Articles
|
|||||||
import Account
|
import Account
|
||||||
|
|
||||||
final class FolderTreeControllerDelegate: TreeControllerDelegate {
|
final class FolderTreeControllerDelegate: TreeControllerDelegate {
|
||||||
|
|
||||||
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
||||||
|
|
||||||
return node.isRoot ? childNodesForRootNode(node) : childNodes(node)
|
return node.isRoot ? childNodesForRootNode(node) : childNodes(node)
|
||||||
@ -21,27 +21,27 @@ final class FolderTreeControllerDelegate: TreeControllerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension FolderTreeControllerDelegate {
|
private extension FolderTreeControllerDelegate {
|
||||||
|
|
||||||
func childNodesForRootNode(_ node: Node) -> [Node]? {
|
func childNodesForRootNode(_ node: Node) -> [Node]? {
|
||||||
|
|
||||||
let accountNodes: [Node] = AccountManager.shared.sortedActiveAccounts.map { account in
|
let accountNodes: [Node] = AccountManager.shared.sortedActiveAccounts.map { account in
|
||||||
let accountNode = Node(representedObject: account, parent: node)
|
let accountNode = Node(representedObject: account, parent: node)
|
||||||
accountNode.canHaveChildNodes = true
|
accountNode.canHaveChildNodes = true
|
||||||
return accountNode
|
return accountNode
|
||||||
}
|
}
|
||||||
return accountNodes
|
return accountNodes
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func childNodes(_ node: Node) -> [Node]? {
|
func childNodes(_ node: Node) -> [Node]? {
|
||||||
|
|
||||||
guard let account = node.representedObject as? Account, let folders = account.folders else {
|
guard let account = node.representedObject as? Account, let folders = account.folders else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let folderNodes: [Node] = folders.map { createNode($0, parent: node) }
|
let folderNodes: [Node] = folders.map { createNode($0, parent: node) }
|
||||||
return folderNodes.sortedAlphabetically()
|
return folderNodes.sortedAlphabetically()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createNode(_ folder: Folder, parent: Node) -> Node {
|
func createNode(_ folder: Folder, parent: Node) -> Node {
|
||||||
@ -49,5 +49,5 @@ private extension FolderTreeControllerDelegate {
|
|||||||
node.canHaveChildNodes = false
|
node.canHaveChildNodes = false
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ struct UserInfoKey {
|
|||||||
static let url = "url"
|
static let url = "url"
|
||||||
static let articlePath = "articlePath"
|
static let articlePath = "articlePath"
|
||||||
static let feedIdentifier = "feedIdentifier"
|
static let feedIdentifier = "feedIdentifier"
|
||||||
|
|
||||||
static let windowState = "windowState"
|
static let windowState = "windowState"
|
||||||
static let windowFullScreenState = "windowFullScreenState"
|
static let windowFullScreenState = "windowFullScreenState"
|
||||||
static let containerExpandedWindowState = "containerExpandedWindowState"
|
static let containerExpandedWindowState = "containerExpandedWindowState"
|
||||||
@ -25,5 +25,5 @@ struct UserInfoKey {
|
|||||||
static let selectedFeedsState = "selectedFeedsState"
|
static let selectedFeedsState = "selectedFeedsState"
|
||||||
static let isShowingExtractedArticle = "isShowingExtractedArticle"
|
static let isShowingExtractedArticle = "isShowingExtractedArticle"
|
||||||
static let articleWindowScrollY = "articleWindowScrollY"
|
static let articleWindowScrollY = "articleWindowScrollY"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -12,19 +12,19 @@ import Articles
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
final class UserNotificationManager: NSObject {
|
final class UserNotificationManager: NSObject {
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
|
||||||
registerCategoriesAndActions()
|
registerCategoriesAndActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func accountDidDownloadArticles(_ note: Notification) {
|
@objc func accountDidDownloadArticles(_ note: Notification) {
|
||||||
guard let articles = note.userInfo?[Account.UserInfoKey.newArticles] as? Set<Article> else {
|
guard let articles = note.userInfo?[Account.UserInfoKey.newArticles] as? Set<Article> else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for article in articles {
|
for article in articles {
|
||||||
if !article.status.read, let feed = article.feed, feed.isNotifyAboutNewArticles ?? false {
|
if !article.status.read, let feed = article.feed, feed.isNotifyAboutNewArticles ?? false {
|
||||||
sendNotification(feed: feed, article: article)
|
sendNotification(feed: feed, article: article)
|
||||||
@ -38,7 +38,7 @@ final class UserNotificationManager: NSObject {
|
|||||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String>,
|
if let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String>,
|
||||||
let statusKey = note.userInfo?[Account.UserInfoKey.statusKey] as? ArticleStatus.Key,
|
let statusKey = note.userInfo?[Account.UserInfoKey.statusKey] as? ArticleStatus.Key,
|
||||||
let flag = note.userInfo?[Account.UserInfoKey.statusFlag] as? Bool,
|
let flag = note.userInfo?[Account.UserInfoKey.statusFlag] as? Bool,
|
||||||
@ -48,14 +48,14 @@ final class UserNotificationManager: NSObject {
|
|||||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension UserNotificationManager {
|
private extension UserNotificationManager {
|
||||||
|
|
||||||
func sendNotification(feed: Feed, article: Article) {
|
func sendNotification(feed: Feed, article: Article) {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
|
|
||||||
content.title = feed.nameForDisplay
|
content.title = feed.nameForDisplay
|
||||||
if !ArticleStringFormatter.truncatedTitle(article).isEmpty {
|
if !ArticleStringFormatter.truncatedTitle(article).isEmpty {
|
||||||
content.subtitle = ArticleStringFormatter.truncatedTitle(article)
|
content.subtitle = ArticleStringFormatter.truncatedTitle(article)
|
||||||
@ -68,11 +68,11 @@ private extension UserNotificationManager {
|
|||||||
if let attachment = thumbnailAttachment(for: article, feed: feed) {
|
if let attachment = thumbnailAttachment(for: article, feed: feed) {
|
||||||
content.attachments.append(attachment)
|
content.attachments.append(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = UNNotificationRequest.init(identifier: "articleID:\(article.articleID)", content: content, trigger: nil)
|
let request = UNNotificationRequest.init(identifier: "articleID:\(article.articleID)", content: content, trigger: nil)
|
||||||
UNUserNotificationCenter.current().add(request)
|
UNUserNotificationCenter.current().add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine if there is an available icon for the article. This will then move it to the caches directory and make it avialble for the notification.
|
/// Determine if there is an available icon for the article. This will then move it to the caches directory and make it avialble for the notification.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - article: `Article`
|
/// - article: `Article`
|
||||||
@ -86,20 +86,20 @@ private extension UserNotificationManager {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerCategoriesAndActions() {
|
func registerCategoriesAndActions() {
|
||||||
let readAction = UNNotificationAction(identifier: "MARK_AS_READ", title: NSLocalizedString("Mark as Read", comment: "Mark as Read"), options: [])
|
let readAction = UNNotificationAction(identifier: "MARK_AS_READ", title: NSLocalizedString("Mark as Read", comment: "Mark as Read"), options: [])
|
||||||
let starredAction = UNNotificationAction(identifier: "MARK_AS_STARRED", title: NSLocalizedString("Mark as Starred", comment: "Mark as Starred"), options: [])
|
let starredAction = UNNotificationAction(identifier: "MARK_AS_STARRED", title: NSLocalizedString("Mark as Starred", comment: "Mark as Starred"), options: [])
|
||||||
let openAction = UNNotificationAction(identifier: "OPEN_ARTICLE", title: NSLocalizedString("Open", comment: "Open"), options: [.foreground])
|
let openAction = UNNotificationAction(identifier: "OPEN_ARTICLE", title: NSLocalizedString("Open", comment: "Open"), options: [.foreground])
|
||||||
|
|
||||||
let newArticleCategory =
|
let newArticleCategory =
|
||||||
UNNotificationCategory(identifier: "NEW_ARTICLE_NOTIFICATION_CATEGORY",
|
UNNotificationCategory(identifier: "NEW_ARTICLE_NOTIFICATION_CATEGORY",
|
||||||
actions: [openAction, readAction, starredAction],
|
actions: [openAction, readAction, starredAction],
|
||||||
intentIdentifiers: [],
|
intentIdentifiers: [],
|
||||||
hiddenPreviewsBodyPlaceholder: "",
|
hiddenPreviewsBodyPlaceholder: "",
|
||||||
options: [])
|
options: [])
|
||||||
|
|
||||||
UNUserNotificationCenter.current().setNotificationCategories([newArticleCategory])
|
UNUserNotificationCenter.current().setNotificationCategories([newArticleCategory])
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -30,4 +30,3 @@ struct LatestArticle: Codable, Identifiable {
|
|||||||
let pubDate: String
|
let pubDate: String
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct WidgetDataDecoder {
|
struct WidgetDataDecoder {
|
||||||
|
|
||||||
static func decodeWidgetData() throws -> WidgetData {
|
static func decodeWidgetData() throws -> WidgetData {
|
||||||
let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String
|
let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String
|
||||||
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
|
||||||
@ -21,7 +21,7 @@ struct WidgetDataDecoder {
|
|||||||
return WidgetData(currentUnreadCount: 0, currentTodayCount: 0, currentStarredCount: 0, unreadArticles: [], starredArticles: [], todayArticles: [], lastUpdateTime: Date())
|
return WidgetData(currentUnreadCount: 0, currentTodayCount: 0, currentStarredCount: 0, unreadArticles: [], starredArticles: [], todayArticles: [], lastUpdateTime: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func sampleData() -> WidgetData {
|
static func sampleData() -> WidgetData {
|
||||||
let pathToSample = Bundle.main.url(forResource: "widget-sample", withExtension: "json")
|
let pathToSample = Bundle.main.url(forResource: "widget-sample", withExtension: "json")
|
||||||
do {
|
do {
|
||||||
@ -32,5 +32,5 @@ struct WidgetDataDecoder {
|
|||||||
return WidgetData(currentUnreadCount: 0, currentTodayCount: 0, currentStarredCount: 0, unreadArticles: [], starredArticles: [], todayArticles: [], lastUpdateTime: Date())
|
return WidgetData(currentUnreadCount: 0, currentTodayCount: 0, currentStarredCount: 0, unreadArticles: [], starredArticles: [], todayArticles: [], lastUpdateTime: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ import RSCore
|
|||||||
import Articles
|
import Articles
|
||||||
import Account
|
import Account
|
||||||
|
|
||||||
|
|
||||||
public final class WidgetDataEncoder {
|
public final class WidgetDataEncoder {
|
||||||
|
|
||||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
|
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
|
||||||
@ -35,39 +34,39 @@ public final class WidgetDataEncoder {
|
|||||||
|
|
||||||
func encode() {
|
func encode() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
flushSharedContainer()
|
flushSharedContainer()
|
||||||
os_log(.debug, log: log, "Starting encoding widget data.")
|
os_log(.debug, log: log, "Starting encoding widget data.")
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.encodeWidgetData() { latestData in
|
self.encodeWidgetData { latestData in
|
||||||
guard let latestData = latestData else {
|
guard let latestData = latestData else {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let encodedData = try? JSONEncoder().encode(latestData)
|
let encodedData = try? JSONEncoder().encode(latestData)
|
||||||
|
|
||||||
os_log(.debug, log: self.log, "Finished encoding widget data.")
|
os_log(.debug, log: self.log, "Finished encoding widget data.")
|
||||||
|
|
||||||
if self.fileExists() {
|
if self.fileExists() {
|
||||||
try? FileManager.default.removeItem(at: self.dataURL!)
|
try? FileManager.default.removeItem(at: self.dataURL!)
|
||||||
os_log(.debug, log: self.log, "Removed widget data from container.")
|
os_log(.debug, log: self.log, "Removed widget data from container.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if FileManager.default.createFile(atPath: self.dataURL!.path, contents: encodedData, attributes: nil) {
|
if FileManager.default.createFile(atPath: self.dataURL!.path, contents: encodedData, attributes: nil) {
|
||||||
os_log(.debug, log: self.log, "Wrote widget data to container.")
|
os_log(.debug, log: self.log, "Wrote widget data to container.")
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func encodeWidgetData(completion: @escaping (WidgetData?) -> Void) {
|
private func encodeWidgetData(completion: @escaping (WidgetData?) -> Void) {
|
||||||
let dispatchGroup = DispatchGroup()
|
let dispatchGroup = DispatchGroup()
|
||||||
var groupError: Error? = nil
|
var groupError: Error?
|
||||||
|
|
||||||
var unread = [LatestArticle]()
|
var unread = [LatestArticle]()
|
||||||
|
|
||||||
@ -142,7 +141,7 @@ public final class WidgetDataEncoder {
|
|||||||
currentStarredCount: (try? AccountManager.shared.fetchCountForStarredArticles()) ?? 0,
|
currentStarredCount: (try? AccountManager.shared.fetchCountForStarredArticles()) ?? 0,
|
||||||
unreadArticles: unread,
|
unreadArticles: unread,
|
||||||
starredArticles: starred,
|
starredArticles: starred,
|
||||||
todayArticles:today,
|
todayArticles: today,
|
||||||
lastUpdateTime: Date())
|
lastUpdateTime: Date())
|
||||||
completion(latestData)
|
completion(latestData)
|
||||||
}
|
}
|
||||||
@ -178,5 +177,3 @@ public final class WidgetDataEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum WidgetDeepLink {
|
enum WidgetDeepLink {
|
||||||
|
|
||||||
case unread
|
case unread
|
||||||
case unreadArticle(id: String)
|
case unreadArticle(id: String)
|
||||||
case today
|
case today
|
||||||
@ -17,7 +17,7 @@ enum WidgetDeepLink {
|
|||||||
case starred
|
case starred
|
||||||
case starredArticle(id: String)
|
case starredArticle(id: String)
|
||||||
case icon
|
case icon
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .unread:
|
case .unread:
|
||||||
@ -42,5 +42,5 @@ enum WidgetDeepLink {
|
|||||||
return URL(string: "nnw://icon")!
|
return URL(string: "nnw://icon")!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user