Clean up ArticleRenderer code a bit. Dropped over 100 lines. Fix #287.

This commit is contained in:
Brent Simmons 2018-11-26 21:38:14 -08:00
parent 6416cb6bf2
commit 0f48ac3e03
1 changed files with 120 additions and 255 deletions

View File

@ -11,78 +11,9 @@ import RSCore
import Articles import Articles
import Account import Account
var cachedStyleString = ""
var cachedTemplate = ""
// NOTE: THIS CODE IS A TOTAL MESS RIGHT NOW WHILE WERE EXPERIMENTING WITH DIFFERENT LAYOUTS. DONT JUDGE, YOU!
class ArticleRenderer { class ArticleRenderer {
let article: Article? let baseURL: URL?
let articleStyle: ArticleStyle
let appearance: NSAppearance?
static var faviconImgTagCache = [Feed: String]()
static var feedIconImgTagCache = [Feed: String]()
lazy var longDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .medium
return dateFormatter
}()
lazy var mediumDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
return dateFormatter
}()
lazy var shortDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
return dateFormatter
}()
lazy var title: String = {
if let articleTitle = self.article?.title {
return articleTitle
}
return ""
}()
lazy var baseURL: URL? = {
var s = self.article?.url
if s == nil {
s = self.article?.feed?.homePageURL
}
if s == nil {
s = self.article?.feed?.url
}
if s == nil {
return nil
}
var urlComponents = URLComponents(string: s!)
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
if let url = urlComponents!.url {
if url.scheme == "http" || url.scheme == "https" {
return url
}
}
return nil
}()
var articleHTML: String { var articleHTML: String {
let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: substitutions(), macroStart: "[[", macroEnd: "]]") let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: substitutions(), macroStart: "[[", macroEnd: "]]")
@ -93,86 +24,70 @@ class ArticleRenderer {
let body = "<h3 class='systemMessage'>Multiple selection</h3>" let body = "<h3 class='systemMessage'>Multiple selection</h3>"
return renderHTML(withBody: body) return renderHTML(withBody: body)
} }
var noSelectionHTML: String { var noSelectionHTML: String {
let body = "<h3 class='systemMessage'>No selection</h3>" let body = "<h3 class='systemMessage'>No selection</h3>"
return renderHTML(withBody: body) return renderHTML(withBody: body)
} }
private let article: Article?
private let articleStyle: ArticleStyle
private let appearance: NSAppearance?
private let title: String
init(article: Article?, style: ArticleStyle, appearance: NSAppearance? = nil) { init(article: Article?, style: ArticleStyle, appearance: NSAppearance? = nil) {
self.article = article self.article = article
self.articleStyle = style self.articleStyle = style
self.appearance = appearance self.appearance = appearance
} self.title = article?.title ?? ""
if let article = article {
// MARK: Private self.baseURL = ArticleRenderer.baseURL(for: article)
private func textInsideTag(_ text: String, _ tag: String) -> String {
return "<\(tag)>\(text)</\(tag)>"
}
private func styleString() -> String {
if let s = articleStyle.css {
return s
} }
else {
if cachedStyleString.isEmpty { self.baseURL = nil
let path = Bundle.main.path(forResource: "styleSheet", ofType: "css")!
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
cachedStyleString = "\n\(s)\n"
} }
}
}
return cachedStyleString // MARK: Private
private extension ArticleRenderer {
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 textInsideTag(_ text: String, _ tag: String) -> String {
// return "<\(tag)>\(text)</\(tag)>"
// }
func styleString() -> String {
return articleStyle.css ?? ArticleRenderer.defaultStyleSheet
} }
private func template() -> String { func template() -> String {
return articleStyle.template ?? ArticleRenderer.defaultTemplate
if let s = articleStyle.template {
return s
}
if cachedTemplate.isEmpty {
let path = Bundle.main.path(forResource: "template", ofType: "html")!
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
cachedTemplate = s as String
}
return cachedTemplate
} }
private func linkWithTextAndClass(_ text: String, _ href: String, _ className: String) -> String { func titleOrTitleLink() -> String {
return "<a class=\"\(className)\" href=\"\(href)\">\(text)</a>"
}
private func linkWithText(_ text: String, _ href: String) -> String {
return ArticleRenderer.linkWithText(text, href)
}
private static func linkWithText(_ text: String, _ href: String) -> String {
return "<a href=\"\(href)\">\(text)</a>"
}
private func linkWithLink(_ href: String) -> String {
return linkWithText(href, href)
}
private func titleOrTitleLink() -> String {
if let link = article?.preferredLink { if let link = article?.preferredLink {
return linkWithText(title, link) return title.htmlByAddingLink(link)
} }
return title return title
} }
private func substitutions() -> [String: String] { func substitutions() -> [String: String] {
var d = [String: String]() var d = [String: String]()
guard let article = article else { guard let article = article else {
@ -183,13 +98,12 @@ class ArticleRenderer {
let title = titleOrTitleLink() let title = titleOrTitleLink()
d["title"] = title d["title"] = title
let body = article.body == nil ? "" : article.body let body = article.body ?? ""
d["body"] = body d["body"] = body
d["avatars"] = "" d["avatars"] = ""
var didAddAvatar = false var didAddAvatar = false
if let avatarHTML = avatarImgTag() { if let avatarHTML = avatarImgTag() {
// d["avatars"] = avatarHTML
d["avatars"] = "<td class=\"header rightAlign avatar\">\(avatarHTML)</td>"; d["avatars"] = "<td class=\"header rightAlign avatar\">\(avatarHTML)</td>";
didAddAvatar = true didAddAvatar = true
} }
@ -198,27 +112,26 @@ class ArticleRenderer {
if let feedTitle = article.feed?.nameForDisplay { if let feedTitle = article.feed?.nameForDisplay {
feedLink = feedTitle feedLink = feedTitle
if let feedURL = article.feed?.homePageURL { if let feedURL = article.feed?.homePageURL {
feedLink = linkWithTextAndClass(feedTitle, feedURL, "feedLink") feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink")
} }
} }
d["feedlink"] = feedLink d["feedlink"] = feedLink
d["feedlink_withfavicon"] = feedLink
// d["favicon"] = ""
if !didAddAvatar, let feed = article.feed { if !didAddAvatar, let feed = article.feed {
if let favicon = faviconImgTag(forFeed: feed) { if let favicon = faviconImgTag(forFeed: feed) {
d["avatars"] = "<td class=\"header rightAlign\">\(favicon)</td>"; d["avatars"] = "<td class=\"header rightAlign\">\(favicon)</td>";
// d["favicon"] = favicon
} }
} }
let longDate = longDateFormatter.string(from: article.logicalDatePublished) let datePublished = article.logicalDatePublished
let mediumDate = mediumDateFormatter.string(from: article.logicalDatePublished) let longDate = dateString(datePublished, .long, .medium)
let shortDate = shortDateFormatter.string(from: article.logicalDatePublished) let mediumDate = dateString(datePublished, .medium, .short)
let shortDate = dateString(datePublished, .short, .short)
if dateShouldBeLink() || self.title == "", let permalink = article.url { if dateShouldBeLink() || self.title == "", let permalink = article.url {
d["date_long"] = linkWithText(longDate, permalink) d["date_long"] = longDate.htmlByAddingLink(permalink)
d["date_medium"] = linkWithText(mediumDate, permalink) d["date_medium"] = mediumDate.htmlByAddingLink(permalink)
d["date_short"] = linkWithText(shortDate, permalink) d["date_short"] = shortDate.htmlByAddingLink(permalink)
} }
else { else {
d["date_long"] = longDate d["date_long"] = longDate
@ -227,12 +140,11 @@ class ArticleRenderer {
} }
d["byline"] = byline() d["byline"] = byline()
// d["author_avatar"] = authorAvatar()
return d return d
} }
private func dateShouldBeLink() -> Bool { func dateShouldBeLink() -> Bool {
guard let permalink = article?.url else { guard let permalink = article?.url else {
return false return false
} }
@ -242,21 +154,7 @@ class ArticleRenderer {
return permalink != preferredLink // Make date a link if its a different link from the titles link return permalink != preferredLink // Make date a link if its a different link from the titles link
} }
struct Avatar { func faviconImgTag(forFeed feed: Feed) -> String? {
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 linkWithText(imageTag, url)
}
return imageTag
}
}
private func faviconImgTag(forFeed feed: Feed) -> String? {
if let cachedImgTag = ArticleRenderer.faviconImgTagCache[feed] { if let cachedImgTag = ArticleRenderer.faviconImgTagCache[feed] {
return cachedImgTag return cachedImgTag
@ -264,14 +162,14 @@ class ArticleRenderer {
if let favicon = appDelegate.faviconDownloader.favicon(for: feed) { if let favicon = appDelegate.faviconDownloader.favicon(for: feed) {
if let s = base64String(forImage: favicon) { if let s = base64String(forImage: favicon) {
var dimension = min(favicon.size.height, CGFloat(avatarDimension)) // Assuming square images. var dimension = min(favicon.size.height, CGFloat(ArticleRenderer.avatarDimension)) // Assuming square images.
dimension = max(dimension, 16) // Some favicons say theyre < 16. Force them larger. dimension = max(dimension, 16) // Some favicons say theyre < 16. Force them larger.
if dimension >= CGFloat(avatarDimension) * 0.8 { //Close enough to scale up. if dimension >= CGFloat(ArticleRenderer.avatarDimension) * 0.8 { //Close enough to scale up.
dimension = CGFloat(avatarDimension) dimension = CGFloat(ArticleRenderer.avatarDimension)
} }
let imgTag: String let imgTag: String
if dimension >= CGFloat(avatarDimension) { if dimension >= CGFloat(ArticleRenderer.avatarDimension) {
// Use rounded corners. // Use rounded corners.
imgTag = "<img src=\"data:image/tiff;base64, " + s + "\" height=\(Int(dimension)) width=\(Int(dimension)) style=\"border-radius:4px\" />" imgTag = "<img src=\"data:image/tiff;base64, " + s + "\" height=\(Int(dimension)) width=\(Int(dimension)) style=\"border-radius:4px\" />"
} }
@ -286,8 +184,7 @@ class ArticleRenderer {
return nil return nil
} }
private func feedIconImgTag(forFeed feed: Feed) -> String? { func feedIconImgTag(forFeed feed: Feed) -> String? {
if let cachedImgTag = ArticleRenderer.feedIconImgTagCache[feed] { if let cachedImgTag = ArticleRenderer.feedIconImgTagCache[feed] {
return cachedImgTag return cachedImgTag
} }
@ -303,116 +200,57 @@ class ArticleRenderer {
return nil return nil
} }
private func base64String(forImage image: NSImage) -> String? { func base64String(forImage image: NSImage) -> String? {
return image.tiffRepresentation?.base64EncodedString()
let d = image.tiffRepresentation
return d?.base64EncodedString()
} }
private func singleArticleSpecifiedAuthor() -> Author? { func singleArticleSpecifiedAuthor() -> Author? {
// The author of this article, if just one. // The author of this article, if just one.
if let authors = article?.authors, authors.count == 1 { if let authors = article?.authors, authors.count == 1 {
return authors.first! return authors.first!
} }
return nil return nil
} }
private func singleFeedSpecifiedAuthor() -> Author? { func singleFeedSpecifiedAuthor() -> Author? {
if let authors = article?.feed?.authors, authors.count == 1 { if let authors = article?.feed?.authors, authors.count == 1 {
return authors.first! return authors.first!
} }
return nil return nil
} }
private func feedAvatar() -> Avatar? { static let avatarDimension = 48
guard let feedIconURL = article?.feed?.iconURL else { struct Avatar {
return nil 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
} }
return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url)
} }
private func authorAvatar() -> Avatar? { func avatarImgTag() -> String? {
if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL { if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url) return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension)
}
if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url)
}
return nil
}
private func avatarsToShow() -> [Avatar]? {
var avatars = [Avatar]()
if let avatar = feedAvatar() {
avatars.append(avatar)
}
if let avatar = authorAvatar() {
avatars.append(avatar)
}
return avatars.isEmpty ? nil : avatars
}
private func avatarToUse() -> Avatar? {
// Use author if article specifies an author, otherwise use feed icon.
// If no feed icon, use feed-specified author.
if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url)
}
if let feedIconURL = article?.feed?.iconURL {
return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url)
}
if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url)
}
return nil
}
private let avatarDimension = 48
private func avatarImgTag() -> String? {
if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url).html(dimension: avatarDimension)
} }
if let feed = article?.feed, let imgTag = feedIconImgTag(forFeed: feed) { if let feed = article?.feed, let imgTag = feedIconImgTag(forFeed: feed) {
return imgTag return imgTag
} }
if let feedIconURL = article?.feed?.iconURL { if let feedIconURL = article?.feed?.iconURL {
return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url).html(dimension: avatarDimension) return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url).html(dimension: ArticleRenderer.avatarDimension)
} }
if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL { if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL {
return Avatar(imageURL: imageURL, url: author.url).html(dimension: avatarDimension) return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension)
} }
return nil return nil
} }
// private func authorAvatar() -> String { func byline() -> String {
//
// guard let authors = article.authors, authors.count == 1, let author = authors.first else {
// return ""
// }
// guard let avatarURL = author.avatarURL else {
// return ""
// }
//
// var imageTag = "<img src=\"\(avatarURL)\" height=64 width=64 />"
// if let authorURL = author.url {
// imageTag = linkWithText(imageTag, authorURL)
// }
// return "<div id=authorAvatar>\(imageTag)</div>"
// }
private func byline() -> String {
guard let authors = article?.authors ?? article?.feed?.authors, !authors.isEmpty else { guard let authors = article?.authors ?? article?.feed?.authors, !authors.isEmpty else {
return "" return ""
} }
@ -439,11 +277,10 @@ class ArticleRenderer {
byline += emailAddress // probably name plus email address byline += emailAddress // probably name plus email address
} }
else if let name = author.name, let url = author.url { else if let name = author.name, let url = author.url {
byline += linkWithText(name, url) byline += name.htmlByAddingLink(url)
} }
else if let name = author.name, let emailAddress = author.emailAddress { else if let name = author.name, let emailAddress = author.emailAddress {
byline += "\(name) &lt;\(emailAddress)&lg;" byline += "\(name) &lt;\(emailAddress)&lg;"
// byline += linkWithText(name, "mailto:\(emailAddress)") //TODO
} }
else if let name = author.name { else if let name = author.name {
byline += name byline += name
@ -452,19 +289,50 @@ class ArticleRenderer {
byline += "&lt;\(emailAddress)&gt;" // TODO: mailto link byline += "&lt;\(emailAddress)&gt;" // TODO: mailto link
} }
else if let url = author.url { else if let url = author.url {
byline += linkWithLink(url) byline += String.htmlWithLink(url)
} }
} }
return byline return byline
} }
private func renderHTML(withBody body: String) -> String { 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)
}
static func baseURL(for article: Article) -> URL? {
var s = article.url
if s == nil {
s = article.feed?.homePageURL
}
if s == nil {
s = article.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
}
func renderHTML(withBody body: String) -> String {
var s = "<!DOCTYPE html><html><head>\n\n" var s = "<!DOCTYPE html><html><head>\n\n"
s += textInsideTag(title, "title") s += title.htmlBySurroundingWithTag("title")
s += textInsideTag(styleString(), "style") s += styleString().htmlBySurroundingWithTag("style")
s += """ s += """
@ -492,15 +360,12 @@ class ArticleRenderer {
let appearanceClass = appearance?.isDarkMode ?? false ? "dark" : "light" let appearanceClass = appearance?.isDarkMode ?? false ? "dark" : "light"
s += "\n\n</head><body id='bodyId' onload='startup()' class=\(appearanceClass)>\n\n" s += "\n\n</head><body id='bodyId' onload='startup()' class=\(appearanceClass)>\n\n"
s += body s += body
s += "\n\n</body></html>" s += "\n\n</body></html>"
//print(s) //print(s)
return s return s
} }
} }