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