NetNewsWire/Shared/Article Rendering/ArticleRenderer.swift

404 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ArticleRenderer.swift
// NetNewsWire
//
// Created by Brent Simmons on 9/8/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import Articles
import Account
struct ArticleRenderer {
typealias Rendering = (style: String, html: String)
typealias Page = (html: String, baseURL: URL)
static var imageIconScheme = "nnwImageIcon"
static var page: Page = {
let pageURL = Bundle.main.url(forResource: "page", withExtension: "html")!
let html = try! String(contentsOf: pageURL)
let baseURL = pageURL.deletingLastPathComponent()
return Page(html: html, baseURL: baseURL)
}()
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 let useImageIcon: Bool
private init(article: Article?, extractedArticle: ExtractedArticle?, style: ArticleStyle, useImageIcon: Bool = false) {
self.article = article
self.extractedArticle = extractedArticle
self.articleStyle = style
self.title = article?.title ?? ""
if let content = extractedArticle?.content {
self.body = content
self.baseURL = extractedArticle?.url
} else {
self.body = article?.body ?? ""
self.baseURL = article?.baseURL?.absoluteString
}
self.useImageIcon = useImageIcon
}
// MARK: - API
static func articleHTML(article: Article, extractedArticle: ExtractedArticle? = nil, style: ArticleStyle, useImageIcon: Bool = false) -> Rendering {
let renderer = ArticleRenderer(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: useImageIcon)
return (renderer.styleString(), renderer.articleHTML)
}
static func multipleSelectionHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.styleString(), renderer.multipleSelectionHTML)
}
static func loadingHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.styleString(), renderer.loadingHTML)
}
static func noSelectionHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.styleString(), renderer.noSelectionHTML)
}
static func noContentHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.styleString(), renderer.noContentHTML)
}
}
// MARK: - Private
private extension ArticleRenderer {
private var articleHTML: String {
let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: articleSubstitutions(), macroStart: "[[", macroEnd: "]]")
return renderHTML(withBody: body)
}
private var multipleSelectionHTML: String {
let body = "<h3 class='systemMessage'>Multiple selection</h3>"
return renderHTML(withBody: body)
}
private var loadingHTML: String {
let body = "<h3 class='systemMessage'>Loading...</h3>"
return renderHTML(withBody: body)
}
private var noSelectionHTML: String {
let body = "<h3 class='systemMessage'>No selection</h3>"
return renderHTML(withBody: body)
}
private var noContentHTML: String {
return renderHTML(withBody: "")
}
static var faviconImgTagCache = [Feed: String]()
static var feedIconImgTagCache = [Feed: String]()
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
d["avatars"] = ""
var didAddAvatar = false
if let avatarHTML = avatarImgTag() {
d["avatars"] = "<td class=\"header rightAlign avatar\">\(avatarHTML)</td>";
didAddAvatar = true
}
var feedLink = ""
if let feedTitle = article.feed?.nameForDisplay {
feedLink = feedTitle
if let feedURL = article.feed?.homePageURL {
feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink")
}
}
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
}
d["byline"] = byline()
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 iconImage = appDelegate.faviconDownloader.faviconAsIcon(for: feed) {
if let s = base64String(forImage: iconImage.image) {
var dimension = min(iconImage.image.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 useImageIcon {
return "<img src=\"\(ArticleRenderer.imageIconScheme)://article.png\" height=48 width=48 />"
}
if let iconImage = appDelegate.feedIconDownloader.icon(for: feed) {
if let s = base64String(forImage: iconImage.image) {
#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? {
return image.dataRepresentation()?.base64EncodedString()
}
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 authorImageURL = author.avatarURL {
let imageURL = useImageIcon ? ArticleRenderer.imageIconScheme : authorImageURL
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 {
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 = ""
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) &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)
}
}
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)
}
func renderHTML(withBody body: String) -> String {
var s = ""
if let baseURL = baseURL {
s += ("<base href=\"" + baseURL + "\"\n>")
}
s += title.htmlBySurroundingWithTag("title")
s += body
return s
}
}
// 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
}
}