// // 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 = "

Multiple selection

" return renderHTML(withBody: body) } private var loadingHTML: String { let body = "

Loading...

" return renderHTML(withBody: body) } private var noSelectionHTML: String { let body = "

No selection

" 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"] = "\(avatarHTML)"; 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"] = "\(favicon)"; } } 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 it’s a different link from the title’s 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 they’re < 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 = "" } else { imgTag = "" } 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 "" } if let iconImage = appDelegate.feedIconDownloader.icon(for: feed) { if let s = base64String(forImage: iconImage.image) { #if os(macOS) let imgTag = "" #else let imgTag = "" #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 = "" 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) <\(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) } func renderHTML(withBody body: String) -> String { var s = "" if let baseURL = baseURL { s += ("") } 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 } // 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 } }