NetNewsWire/Shared/Article Rendering/ArticleRenderer.swift

416 lines
11 KiB
Swift
Raw Normal View History

2017-05-27 19:43:27 +02:00
//
// ArticleRenderer.swift
2018-08-29 07:18:24 +02:00
// NetNewsWire
2017-05-27 19:43:27 +02:00
//
// Created by Brent Simmons on 9/8/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import Articles
import Account
2017-05-27 19:43:27 +02:00
2019-02-11 07:06:03 +01:00
struct ArticleRenderer {
private let article: Article?
private let articleStyle: ArticleStyle
private let title: String
private let baseURL: String?
private init(article: Article?, style: ArticleStyle) {
2017-05-27 19:43:27 +02:00
self.article = article
self.articleStyle = style
self.title = article?.title ?? ""
self.baseURL = article?.baseURL?.absoluteString
2017-05-27 19:43:27 +02:00
}
2019-02-11 07:06:03 +01:00
// MARK: - API
static func articleHTML(article: Article, style: ArticleStyle) -> String {
let renderer = ArticleRenderer(article: article, style: style)
return renderer.articleHTML
2019-02-11 07:06:03 +01:00
}
static func multipleSelectionHTML(style: ArticleStyle) -> String {
let renderer = ArticleRenderer(article: nil, style: style)
return renderer.multipleSelectionHTML
2019-02-11 07:06:03 +01:00
}
static func noSelectionHTML(style: ArticleStyle) -> String {
let renderer = ArticleRenderer(article: nil, style: style)
return renderer.noSelectionHTML
}
}
2017-05-27 19:43:27 +02:00
2019-02-11 07:06:03 +01:00
// MARK: - Private
2017-05-27 19:43:27 +02:00
private extension ArticleRenderer {
2017-05-27 19:43:27 +02:00
2019-02-11 07:06:03 +01:00
private var articleHTML: String {
let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: substitutions(), macroStart: "[[", macroEnd: "]]")
return renderHTML(withBody: body)
}
private var multipleSelectionHTML: String {
let body = "<h3 class='systemMessage'>Multiple selection</h3>"
return renderHTML(withBody: body)
}
private var noSelectionHTML: String {
let body = "<h3 class='systemMessage'>No selection</h3>"
return renderHTML(withBody: body)
}
static var faviconImgTagCache = [Feed: String]()
static var feedIconImgTagCache = [Feed: String]()
2017-05-27 19:43:27 +02:00
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"
}()
2017-05-27 19:43:27 +02:00
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
2017-05-27 19:43:27 +02:00
}
func template() -> String {
return articleStyle.template ?? ArticleRenderer.defaultTemplate
2017-12-29 20:31:47 +01:00
}
func titleOrTitleLink() -> String {
if let link = article?.preferredLink {
return title.htmlByAddingLink(link)
2017-05-27 19:43:27 +02:00
}
return title
}
func substitutions() -> [String: String] {
2017-05-27 19:43:27 +02:00
var d = [String: String]()
guard let article = article else {
assertionFailure("Article should have been set before calling this function.")
return d
}
2017-05-27 19:43:27 +02:00
let title = titleOrTitleLink()
d["title"] = title
2017-05-27 19:43:27 +02:00
let body = article.body ?? ""
d["body"] = body
2017-05-27 19:43:27 +02:00
d["avatars"] = ""
var didAddAvatar = false
if let avatarHTML = avatarImgTag() {
d["avatars"] = "<td class=\"header rightAlign avatar\">\(avatarHTML)</td>";
didAddAvatar = true
}
2017-05-27 19:43:27 +02:00
var feedLink = ""
if let feedTitle = article.feed?.nameForDisplay {
feedLink = feedTitle
if let feedURL = article.feed?.homePageURL {
feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink")
2017-05-27 19:43:27 +02:00
}
}
d["feedlink"] = feedLink
if !didAddAvatar, let feed = article.feed {
if let favicon = faviconImgTag(forFeed: feed) {
d["avatars"] = "<td class=\"header rightAlign\">\(favicon)</td>";
}
}
let datePublished = article.logicalDatePublished
let longDate = dateString(datePublished, .long, .medium)
let mediumDate = dateString(datePublished, .medium, .short)
let shortDate = dateString(datePublished, .short, .short)
if dateShouldBeLink() || self.title == "", 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
}
2017-12-29 20:31:47 +01:00
d["byline"] = byline()
2017-05-27 19:43:27 +02:00
return d
}
func dateShouldBeLink() -> Bool {
guard let permalink = article?.url else {
return false
}
guard let preferredLink = article?.preferredLink else { // Title uses preferredLink
return false
}
return permalink != preferredLink // Make date a link if its a different link from the titles link
}
func faviconImgTag(forFeed feed: Feed) -> String? {
if let cachedImgTag = ArticleRenderer.faviconImgTagCache[feed] {
return cachedImgTag
}
if let favicon = appDelegate.faviconDownloader.favicon(for: feed) {
if let s = base64String(forImage: favicon) {
var dimension = min(favicon.size.height, CGFloat(ArticleRenderer.avatarDimension)) // Assuming square images.
dimension = max(dimension, 16) // Some favicons say theyre < 16. Force them larger.
if dimension >= CGFloat(ArticleRenderer.avatarDimension) * 0.8 { //Close enough to scale up.
dimension = CGFloat(ArticleRenderer.avatarDimension)
}
let imgTag: String
if dimension >= CGFloat(ArticleRenderer.avatarDimension) {
// Use rounded corners.
imgTag = "<img src=\"data:image/tiff;base64, " + s + "\" height=\(Int(dimension)) width=\(Int(dimension)) style=\"border-radius:4px\" />"
}
else {
imgTag = "<img src=\"data:image/tiff;base64, " + s + "\" height=\(Int(dimension)) width=\(Int(dimension)) />"
}
ArticleRenderer.faviconImgTagCache[feed] = imgTag
return imgTag
}
}
return nil
}
func feedIconImgTag(forFeed feed: Feed) -> String? {
if let cachedImgTag = ArticleRenderer.feedIconImgTagCache[feed] {
return cachedImgTag
}
if let icon = appDelegate.feedIconDownloader.icon(for: feed) {
if let s = base64String(forImage: icon) {
#if os(macOS)
let imgTag = "<img src=\"data:image/tiff;base64, " + s + "\" height=48 width=48 />"
#else
let imgTag = "<img src=\"data:image/png;base64, " + s + "\" height=48 width=48 />"
#endif
ArticleRenderer.feedIconImgTagCache[feed] = imgTag
return imgTag
}
}
return nil
}
func base64String(forImage image: RSImage) -> String? {
#if os(macOS)
return image.tiffRepresentation?.base64EncodedString()
#else
return image.pngData()?.base64EncodedString()
#endif
}
func singleArticleSpecifiedAuthor() -> Author? {
// The author of this article, if just one.
if let authors = article?.authors, authors.count == 1 {
return authors.first!
}
return nil
}
func singleFeedSpecifiedAuthor() -> Author? {
if let authors = article?.feed?.authors, authors.count == 1 {
return authors.first!
}
return nil
}
static let avatarDimension = 48
struct Avatar {
let imageURL: String
let url: String?
func html(dimension: Int) -> String {
let imageTag = "<img src=\"\(imageURL)\" width=\(dimension) height=\(dimension) />"
if let url = url {
return imageTag.htmlByAddingLink(url)
}
return imageTag
}
}
func avatarImgTag() -> String? {
if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension)
}
if let feed = article?.feed, let imgTag = feedIconImgTag(forFeed: feed) {
return imgTag
}
if let feedIconURL = article?.feed?.iconURL {
return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url).html(dimension: ArticleRenderer.avatarDimension)
}
if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension)
}
return nil
}
func byline() -> String {
guard let authors = article?.authors ?? article?.feed?.authors, !authors.isEmpty else {
2017-12-29 20:31:47 +01:00
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?.feed?.nameForDisplay {
return ""
}
}
var byline = ""
2017-12-29 20:31:47 +01:00
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)
2017-12-29 20:31:47 +01:00
}
else if let name = author.name, let emailAddress = author.emailAddress {
byline += "\(name) &lt;\(emailAddress)&lg;"
}
else if let name = author.name {
byline += name
}
else if let emailAddress = author.emailAddress {
byline += "&lt;\(emailAddress)&gt;" // TODO: mailto link
}
else if let url = author.url {
byline += String.htmlWithLink(url)
2017-12-29 20:31:47 +01:00
}
}
return byline
2017-12-29 20:31:47 +01:00
}
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(macOS)
func renderHTML(withBody body: String) -> String {
2017-05-27 19:43:27 +02:00
var s = "<!DOCTYPE html><html><head>\n\n"
if let baseURL = baseURL {
s += ("<base href=\"" + baseURL + "\"\n>")
}
s += title.htmlBySurroundingWithTag("title")
s += styleString().htmlBySurroundingWithTag("style")
s += """
<script type="text/javascript">
function startup() {
var anchors = document.getElementsByTagName("a");
for (var i = 0; i < anchors.length; i++) {
anchors[i].addEventListener("mouseenter", function() { mouseDidEnterLink(this) });
anchors[i].addEventListener("mouseleave", function() { mouseDidExitLink(this) });
}
}
function mouseDidEnterLink(anchor) {
window.webkit.messageHandlers.mouseDidEnter.postMessage(anchor.href);
}
function mouseDidExitLink(anchor) {
window.webkit.messageHandlers.mouseDidExit.postMessage(anchor.href);
}
</script>
"""
s += "\n\n</head><body onload='startup()'>\n\n"
s += body
s += "\n\n</body></html>"
2017-05-27 19:43:27 +02:00
//print(s)
2017-05-27 19:43:27 +02:00
return s
}
#else
func renderHTML(withBody body: String) -> String {
var s = "<!DOCTYPE html><html><head>\n"
if let baseURL = baseURL {
s += ("<base href=\"" + baseURL + "\"\n>")
}
s += "<meta name=\"viewport\" content=\"width=device-width\">\n"
s += title.htmlBySurroundingWithTag("title")
s += styleString().htmlBySurroundingWithTag("style")
s += "\n\n</head><body>\n\n"
s += body
s += "\n\n</body></html>"
return s
}
#endif
2017-05-27 19:43:27 +02:00
}
// MARK: - Article extension
private extension Article {
var baseURL: URL? {
var s = url
if s == nil {
s = feed?.homePageURL
}
if s == nil {
s = feed?.url
}
guard let urlString = s else {
return nil
}
var urlComponents = URLComponents(string: urlString)
if urlComponents == nil {
return nil
}
// Cant use url-with-fragment as base URL. The webview wont 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
}
}