//
// 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 Articles
import Account
import Core
import ArticleExtractor
@MainActor 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 let imageIconScheme = "nnwImageIcon"
static let blank = Page(name: "blank")
static let 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
formatter.timeStyle = .medium
return formatter
}()
private static let mediumDateTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
private static let shortDateTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
private static let longDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()
private static let mediumDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()
private static let shortDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
return formatter
}()
private static let longTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .long
return formatter
}()
private static let mediumTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
return formatter
}()
private static let shortTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private init(article: Article?, extractedArticle: ExtractedArticle?, theme: ArticleTheme) {
self.article = article
self.extractedArticle = extractedArticle
self.articleTheme = theme
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, theme: ArticleTheme) -> Rendering {
let renderer = ArticleRenderer(article: article, extractedArticle: extractedArticle, theme: theme)
return (renderer.articleCSS, renderer.articleHTML, renderer.title, renderer.baseURL ?? "")
}
static func multipleSelectionHTML(theme: ArticleTheme) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
return (renderer.articleCSS, renderer.multipleSelectionHTML, renderer.title, renderer.baseURL ?? "")
}
static func loadingHTML(theme: ArticleTheme) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, theme: theme)
return (renderer.articleCSS, renderer.loadingHTML, renderer.title, renderer.baseURL ?? "")
}
static func noSelectionHTML(theme: ArticleTheme) -> Rendering {
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 ?? "")
}
}
// 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 articleTheme.css ?? ArticleRenderer.defaultStyleSheet
}
func template() -> String {
return articleTheme.template ?? ArticleRenderer.defaultTemplate
}
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
}
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
d["external_link"] = externalLink
} else {
d["external_link_label"] = ""
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 {
d["avatar_src"] = ""
}
if self.title.isEmpty {
d["dateline_style"] = "articleDatelineTitle"
} else {
d["dateline_style"] = "articleDateline"
}
d["feed_link_title"] = article.feed?.nameForDisplay ?? ""
d["feed_link"] = article.feed?.homePageURL ?? ""
d["byline"] = byline()
let datePublished = article.logicalDatePublished
d["datetime_long"] = Self.longDateTimeFormatter.string(from: datePublished)
d["datetime_medium"] = Self.mediumDateTimeFormatter.string(from: datePublished)
d["datetime_short"] = Self.shortDateTimeFormatter.string(from: datePublished)
d["date_long"] = Self.longDateFormatter.string(from: datePublished)
d["date_medium"] = Self.mediumDateFormatter.string(from: datePublished)
d["date_short"] = Self.shortDateFormatter.string(from: datePublished)
d["time_long"] = Self.longTimeFormatter.string(from: datePublished)
d["time_medium"] = Self.mediumTimeFormatter.string(from: datePublished)
d["time_short"] = Self.shortTimeFormatter.string(from: datePublished)
return d
}
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
var authorEmailAddress: String? = nil
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 {
byline += name.htmlByAddingLink(url)
}
else if let name = author.name, let emailAddress = authorEmailAddress {
byline += "\(name) <\(emailAddress)>"
}
else if let name = author.name {
byline += name
}
else if let emailAddress = authorEmailAddress {
byline += "<\(emailAddress)>" // TODO: mailto link
}
else if let url = author.url {
byline += String.htmlWithLink(url)
}
}
return byline
}
#if os(iOS)
func styleSubstitutions() -> [String: String] {
var d = [String: String]()
let bodyFont = UIFont.preferredFont(forTextStyle: .body)
d["font-size"] = String(describing: bodyFont.pointSize)
return d
}
#else
func styleSubstitutions() -> [String: String] {
return [String: String]()
}
#endif
}
// MARK: - Article extension
@MainActor private extension Article {
var baseURL: URL? {
var s = link
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
}
// 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
}
}