From a0d2dadc16b20d4312921ee8dfbbd123bfb48b28 Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Wed, 10 Jan 2024 17:53:57 -0500 Subject: [PATCH] Add recent factbook edits RSS feed --- .../kotlin/info/mechyrdia/Factbooks.kt | 6 + .../kotlin/info/mechyrdia/lore/view_nav.kt | 7 +- .../kotlin/info/mechyrdia/lore/views_rss.kt | 219 ++++++++++++++++++ src/jvmMain/resources/static/style.css | 2 +- 4 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt diff --git a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt index 7e2ebc5..9892827 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt @@ -164,6 +164,12 @@ fun Application.factbooks() { call.respondText(buildString { generateSitemap() }, ContentType.Application.Xml) } + // Routes for cyborgs + + get("/edits.rss") { + call.respondText(buildString { generateRecentPageEdits() }, ContentType.Application.Rss) + } + // Client settings get("/change-theme") { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt index ff66f3b..05a40e6 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt @@ -30,7 +30,7 @@ fun loadExternalLinks(): List { suspend fun ApplicationCall.standardNavBar(path: List? = null) = listOf( NavLink("/", "Lore Intro"), - NavLink("/lore", "Table of Contents") + NavLink("/lore", "Table of Contents"), ) + path?.let { pathParts -> pathParts.dropLast(1).mapIndexed { i, part -> val subPath = pathParts.take(i + 1).joinToString("/") @@ -44,15 +44,16 @@ suspend fun ApplicationCall.standardNavBar(path: List? = null) = listOf( NavHead(data.name), NavLink("/user/${data.id}", "Your User Page"), NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"), - NavLink("/auth/logout", "Log Out", linkAttributes = mapOf("data-method" to "post", "data-csrf-token" to createCsrfToken("/auth/logout"))) + NavLink("/auth/logout", "Log Out", linkAttributes = mapOf("data-method" to "post", "data-csrf-token" to createCsrfToken("/auth/logout"))), ) } ?: listOf( NavHead("Log In"), - NavLink("/auth/login", "Log In with NationStates") + NavLink("/auth/login", "Log In with NationStates"), )) + listOf( NavHead("Useful Links"), NavLink("/comment/help", "Commenting Help"), NavLink("/comment/recent", "Recent Comments"), + NavLink("/edits.rss", "RSS Feed: Page Updates"), ) + loadExternalLinks() sealed class NavItem { diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt new file mode 100644 index 0000000..021f5c3 --- /dev/null +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt @@ -0,0 +1,219 @@ +package info.mechyrdia.lore + +import info.mechyrdia.Configuration +import io.ktor.util.* +import java.io.File +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +context(MutableList) +private fun ArticleNode.addPages(base: String? = null) { + if (!this.isViewable) + return + val path = base?.let { "$it/$name" } ?: name + val file = File(Configuration.CurrentConfiguration.articleDir).combineSafe(path) + if (file.isFile) + add(file) + else for (subNode in subNodes) + subNode.addPages(path) +} + +private fun allPages(): List { + return buildList { + for (node in rootArticleNodeList()) + node.addPages() + } +} + +fun Appendable.generateRecentPageEdits() { + val pages = allPages().sortedByDescending { it.lastModified() } + + val mostRecentChange = pages.firstOrNull()?.lastModified()?.let { Instant.ofEpochMilli(it) } + + RssChannel( + title = "Recently Edited Factbooks | The Hour of Decision", + link = "https://mechyrdia.info", + description = "A RSS news feed containing all factbooks in The Hour of Decision, in order of most recently edited.", + pubDate = mostRecentChange, + lastBuildDate = mostRecentChange, + ttl = 30, + categories = listOf( + RssCategory(domain = "https://nationstates.net", category = "Mechyrdia") + ), + items = pages.map { page -> + val pagePath = page.toRelativeString(File(Configuration.CurrentConfiguration.articleDir)).replace('\\', '/') + + val pageTemplate = page.readText() + val pageMarkup = PreParser.preparse(pagePath, pageTemplate) + + val pageToC = TableOfContentsBuilder() + TextParserState.parseText(pageMarkup, TextParserToCBuilderTag.asTags, pageToC) + val pageOg = pageToC.toOpenGraph() + + val imageEnclosure = pageOg?.image?.let { url -> + val assetPath = url.removePrefix("https://mechyrdia.info/assets/") + val file = File(Configuration.CurrentConfiguration.assetDir).combineSafe(assetPath) + RssItemEnclosure( + url = url, + length = file.length(), + type = when { + url.endsWith(".png") -> "image/png" + url.endsWith(".gif") -> "image/gif" + url.endsWith(".jpg") || url.endsWith(".jpeg") -> "image/jpeg" + url.endsWith(".svg") -> "image/svg+xml" + else -> "application/octet-stream" + } + ) + } + + RssItem( + title = pageToC.toPageTitle(), + description = pageOg?.desc, + link = "https://mechyrdia.info/lore/$pagePath", + author = null, + comments = "https://mechyrdia.info/lore/$pagePath#comments", + enclosure = imageEnclosure, + pubDate = Instant.ofEpochMilli(page.lastModified()) + ) + } + ).toXml(this) +} + +data class RssCategory( + val category: String, + val domain: String? = null +) { + fun toXml(appendable: Appendable) { + appendable.append("") + else + appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">") + appendable.append(category.escapeHTML()).appendLine("") + } +} + +data class RssChannelImage( + val url: String, + val title: String, + val link: String, +) { + fun toXml(appendable: Appendable) { + appendable.appendLine("") + appendable.append("").append(url.escapeHTML()).appendLine("") + appendable.append("").append(title.escapeHTML()).appendLine("") + appendable.append("").append(link.escapeHTML()).appendLine("") + appendable.appendLine("") + } +} + +const val DEFAULT_RSS_COPYRIGHT = "Copyright 2022 Lanius Trolling" +const val DEFAULT_RSS_EMAIL = "lanius@laniustrolling.dev (Lanius Trolling)" + +val RssDateFormat: DateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME +fun Instant.toXml() = RssDateFormat.format(atOffset(ZoneOffset.UTC)).escapeHTML() + +data class RssChannel( + val title: String, + val link: String, + val description: String, + val language: String? = "en-us", + val copyright: String? = DEFAULT_RSS_COPYRIGHT, + val managingEditor: String? = DEFAULT_RSS_EMAIL, + val webMaster: String? = managingEditor, + val pubDate: Instant? = null, + val lastBuildDate: Instant? = null, + val ttl: Int? = null, + val image: RssChannelImage? = null, + val categories: List = emptyList(), + val items: List = emptyList(), +) { + fun toXml(appendable: Appendable) { + appendable.appendLine("") + appendable.appendLine("") + appendable.appendLine("") + + appendable.append("").append(title.escapeHTML()).appendLine("") + appendable.append("").append(link.escapeHTML()).appendLine("") + appendable.append("").append(description.escapeHTML()).appendLine("") + + if (language != null) + appendable.append("").append(language.escapeHTML()).appendLine("") + if (copyright != null) + appendable.append("").append(copyright.escapeHTML()).appendLine("") + if (managingEditor != null) + appendable.append("").append(managingEditor.escapeHTML()).appendLine("") + if (webMaster != null) + appendable.append("").append(webMaster.escapeHTML()).appendLine("") + if (pubDate != null) + appendable.append("").append(pubDate.toXml()).appendLine("") + if (lastBuildDate != null) + appendable.append("").append(lastBuildDate.toXml()).appendLine("") + if (ttl != null) + appendable.append("").append(ttl.toString()).appendLine("") + + image?.toXml(appendable) + + for (category in categories) + category.toXml(appendable) + for (item in items) + item.toXml(appendable) + + appendable.appendLine("") + appendable.appendLine("") + } +} + +data class RssItemEnclosure( + val url: String, + val length: Long, + val type: String, +) { + fun toXml(appendable: Appendable) { + appendable.append("") + } +} + +data class RssItem( + val title: String? = null, + val description: String? = null, + val link: String? = null, + val author: String? = DEFAULT_RSS_EMAIL, + val comments: String? = null, + val enclosure: RssItemEnclosure? = null, + val pubDate: Instant? = null, + val categories: List = emptyList(), +) { + init { + require(title != null || description != null) { "Either title or description must be provided, got null for both" } + } + + fun toXml(appendable: Appendable) { + appendable.appendLine("") + + if (title != null) + appendable.append("").append(title.escapeHTML()).appendLine("") + if (description != null) + appendable.append("").append(description.escapeHTML()).appendLine("") + if (link != null) + appendable.append("").append(link.escapeHTML()).appendLine("") + if (author != null) + appendable.append("").append(author.escapeHTML()).appendLine("") + if (comments != null) + appendable.append("").append(comments.escapeHTML()).appendLine("") + enclosure?.toXml(appendable) + + if (pubDate != null) + appendable.append("").append(pubDate.toXml()).appendLine("") + + for (category in categories) + category.toXml(appendable) + + appendable.appendLine("") + } +} diff --git a/src/jvmMain/resources/static/style.css b/src/jvmMain/resources/static/style.css index 24497e4..d53625d 100644 --- a/src/jvmMain/resources/static/style.css +++ b/src/jvmMain/resources/static/style.css @@ -481,7 +481,7 @@ aside.mobile img { width: 50%; } -@media only screen and (min-width: 8in) { +@media only screen and (min-width: 9.6in) { html { padding: 1.25rem 4vw; -- 2.25.1