// // ArticleRenderer.swift // NetNewsWire // // Created by Brent Simmons on 9/8/15. // Copyright © 2015 Ranchero Software, LLC. All rights reserved. // import Foundation #if os(iOS) import UIKit #endif import RSCore import Articles 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() html = try! NSString(contentsOfFile: url.path, encoding: String.Encoding.utf8.rawValue) as String } } 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 articleStyle: ArticleStyle private let title: String private let body: String private let baseURL: String? private init(article: Article?, extractedArticle: ExtractedArticle?, style: ArticleStyle) { self.article = article self.extractedArticle = extractedArticle self.articleStyle = style self.title = article?.sanitizedTitle() ?? "" if let content = extractedArticle?.content { self.body = content self.baseURL = extractedArticle?.url } else { self.body = article?.body ?? "" self.baseURL = article?.baseURL?.absoluteString } } // MARK: - API static func articleHTML(article: Article, extractedArticle: ExtractedArticle? = nil, style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: article, extractedArticle: extractedArticle, style: style) return (renderer.articleCSS, renderer.articleHTML, renderer.title, renderer.baseURL ?? "") } static func multipleSelectionHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) return (renderer.articleCSS, renderer.multipleSelectionHTML, renderer.title, renderer.baseURL ?? "") } static func loadingHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) return (renderer.articleCSS, renderer.loadingHTML, renderer.title, renderer.baseURL ?? "") } static func noSelectionHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) return (renderer.articleCSS, renderer.noSelectionHTML, renderer.title, renderer.baseURL ?? "") } static func noContentHTML(style: ArticleStyle) -> Rendering { let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style) return (renderer.articleCSS, renderer.noContentHTML, renderer.title, renderer.baseURL ?? "") } } // MARK: - Private private extension ArticleRenderer { private var articleHTML: String { return try! MacroProcessor.renderedText(withTemplate: template(), substitutions: articleSubstitutions()) } private var multipleSelectionHTML: String { let body = "

Multiple selection

" return body } private var loadingHTML: String { let body = "

Loading...

" return body } private var noSelectionHTML: String { let body = "

No selection

" return body } private var noContentHTML: String { return "" } private var articleCSS: String { return try! MacroProcessor.renderedText(withTemplate: styleString(), substitutions: styleSubstitutions()) } static var defaultStyleSheet: String = { let path = Bundle.main.path(forResource: "styleSheet", ofType: "css")! let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) return "\n\(s)\n" }() static let defaultTemplate: String = { let path = Bundle.main.path(forResource: "template", ofType: "html")! let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) return s as String }() func styleString() -> String { return articleStyle.css ?? ArticleRenderer.defaultStyleSheet } func template() -> String { return articleStyle.template ?? ArticleRenderer.defaultTemplate } func titleOrTitleLink() -> String { if let link = article?.preferredLink { return title.htmlByAddingLink(link) } return title } func articleSubstitutions() -> [String: String] { var d = [String: String]() guard let article = article else { assertionFailure("Article should have been set before calling this function.") return d } let title = titleOrTitleLink() d["title"] = title d["body"] = body var components = URLComponents() components.scheme = Self.imageIconScheme components.path = article.articleID if let imageIconURLString = components.string { d["avatars"] = "" } else { d["avatars"] = "" } var feedLink = "" if let feedTitle = article.webFeed?.nameForDisplay { feedLink = feedTitle if let feedURL = article.webFeed?.homePageURL { feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink") } } d["feedlink"] = feedLink let datePublished = article.logicalDatePublished let longDate = dateString(datePublished, .long, .medium) let mediumDate = dateString(datePublished, .medium, .short) let shortDate = dateString(datePublished, .short, .short) if let permalink = article.url { d["date_long"] = longDate.htmlByAddingLink(permalink) d["date_medium"] = mediumDate.htmlByAddingLink(permalink) d["date_short"] = shortDate.htmlByAddingLink(permalink) } else { d["date_long"] = longDate d["date_medium"] = mediumDate d["date_short"] = shortDate } d["byline"] = byline() return d } func byline() -> String { guard let authors = article?.authors ?? article?.webFeed?.authors, !authors.isEmpty else { return "" } // If the author's name is the same as the feed, then we don't want to display it. // This code assumes that multiple authors would never match the feed name so that // if there feed owner has an article co-author all authors are given the byline. if authors.count == 1, let author = authors.first { if author.name == article?.webFeed?.nameForDisplay { return "" } } var byline = "" var isFirstAuthor = true for author in authors { if !isFirstAuthor { byline += ", " } isFirstAuthor = false if let emailAddress = author.emailAddress, emailAddress.contains(" ") { byline += emailAddress // probably name plus email address } else if let name = author.name, let url = author.url { byline += name.htmlByAddingLink(url) } else if let name = author.name, let emailAddress = author.emailAddress { byline += "\(name) <\(emailAddress)>" } else if let name = author.name { byline += name } else if let emailAddress = author.emailAddress { byline += "<\(emailAddress)>" // TODO: mailto link } else if let url = author.url { byline += String.htmlWithLink(url) } } return byline } func dateString(_ date: Date, _ dateStyle: DateFormatter.Style, _ timeStyle: DateFormatter.Style) -> String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = dateStyle dateFormatter.timeStyle = timeStyle return dateFormatter.string(from: date) } #if os(iOS) func styleSubstitutions() -> [String: String] { var d = [String: String]() let bodyFont = UIFont.preferredFont(forTextStyle: .body) d["font-size"] = String(describing: bodyFont.pointSize) if let components = UIColor(named: "AccentColor")?.cgColor.components { d["accent-r"] = String(Int(round(components[0] * 0xFF))) d["accent-g"] = String(Int(round(components[1] * 0xFF))) d["accent-b"] = String(Int(round(components[2] * 0xFF))) } return d } #else func styleSubstitutions() -> [String: String] { var d = [String: String]() #if SWIFTUI let bodyFont = NSFont.preferredFont(forTextStyle: .body) d["font-size"] = String(describing: Int(round(bodyFont.pointSize * 1.33))) #endif guard let linkColor = NSColor.controlAccentColor.usingColorSpace(.deviceRGB) else { return d } let red: Int let green: Int let blue: Int if NSApplication.shared.effectiveAppearance.isDarkMode { let brighten = CGFloat(0.50) let baseRed = linkColor.redComponent * 0xFF red = Int(round(((255 - baseRed) * brighten)) + round(baseRed)) let baseGreen = linkColor.greenComponent * 0xFF green = Int(round(((255 - baseGreen) * brighten)) + round(baseGreen)) let baseBlue = linkColor.blueComponent * 0xFF blue = Int(round(((255 - baseBlue) * brighten)) + round(baseBlue)) } else { let darken = CGFloat(0.75) red = Int(round(linkColor.redComponent * 0xFF * darken)) green = Int(round(linkColor.greenComponent * 0xFF * darken)) blue = Int(round(linkColor.blueComponent * 0xFF * darken)) } d["accent-r"] = String(red) d["accent-g"] = String(green) d["accent-b"] = String(blue) return d } #endif } // MARK: - Article extension private extension Article { var baseURL: URL? { var s = url if s == nil { s = webFeed?.homePageURL } if s == nil { s = webFeed?.url } guard let urlString = s else { return nil } var urlComponents = URLComponents(string: urlString) if urlComponents == nil { return nil } // Can’t use url-with-fragment as base URL. The webview won’t load. See scripting.com/rss.xml for example. urlComponents!.fragment = nil guard let url = urlComponents!.url, url.scheme == "http" || url.scheme == "https" else { return nil } return url } }