Fix lint issues.

This commit is contained in:
Brent Simmons 2025-01-22 22:20:08 -08:00
parent bbef99f2d3
commit 10f4351904
64 changed files with 506 additions and 545 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,6 @@
import Foundation import Foundation
struct Constants { struct Constants {
static let prototypeText = "You are about to being reading Italo Calvinos new novel, *If on a winters 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 dont want to watch TV!” Raise your voice—they wont hear you otherwise—“Im reading! I dont want to be disturbed!” Maybe they havent heard you, with all that racket; speak louder, yell: “Im beginning to read Italo Calvinos new novel!” Or if you prefer, dont say anything; just hope theyll 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 Calvinos new novel, *If on a winters 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 dont want to watch TV!” Raise your voice—they wont hear you otherwise—“Im reading! I dont want to be disturbed!” Maybe they havent heard you, with all that racket; speak louder, yell: “Im beginning to read Italo Calvinos new novel!” Or if you prefer, dont say anything; just hope theyll 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."
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -116,4 +116,3 @@ struct ArticleStringFormatter {
return dateFormatter.string(from: date) return dateFormatter.string(from: date)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,4 +31,3 @@ struct ImageUtilities {
return false return false
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ import Account
import RSCore import RSCore
protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider { protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider {
} }
#endif #endif

View File

@ -36,4 +36,3 @@ struct SearchFeedDelegate: SmartFeedDelegate {
// TODO: after 5.0 // TODO: after 5.0
} }
} }

View File

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

View File

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

View File

@ -40,4 +40,3 @@ import RSCore
return plist return plist
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,4 +30,3 @@ struct LatestArticle: Codable, Identifiable {
let pubDate: String let pubDate: String
} }

View File

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

View File

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

View File

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