Add recent factbook edits RSS feed
authorLanius Trolling <lanius@laniustrolling.dev>
Wed, 10 Jan 2024 22:53:57 +0000 (17:53 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Wed, 10 Jan 2024 23:14:13 +0000 (18:14 -0500)
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_rss.kt [new file with mode: 0644]
src/jvmMain/resources/static/style.css

index 7e2ebc5a9f6516db598063004cbca0fc771f17b1..9892827e4be285bcb8ab452a90b96a3c6f5681eb 100644 (file)
@@ -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") {
index ff66f3b0140e418bcbb6056a739f0222fe3c8ec2..05a40e6722d9a70cbc768a63ecbbe8a031b1db83 100644 (file)
@@ -30,7 +30,7 @@ fun loadExternalLinks(): List<NavItem> {
 
 suspend fun ApplicationCall.standardNavBar(path: List<String>? = 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<String>? = 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 (file)
index 0000000..021f5c3
--- /dev/null
@@ -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<File>)
+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<File> {
+       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("<category")
+               if (domain == null)
+                       appendable.append(">")
+               else
+                       appendable.append(" domain=\"").append(domain.escapeHTML()).append("\">")
+               appendable.append(category.escapeHTML()).appendLine("</category>")
+       }
+}
+
+data class RssChannelImage(
+       val url: String,
+       val title: String,
+       val link: String,
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<image>")
+               appendable.append("<url>").append(url.escapeHTML()).appendLine("</url>")
+               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               appendable.appendLine("</image>")
+       }
+}
+
+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<RssCategory> = emptyList(),
+       val items: List<RssItem> = emptyList(),
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
+               appendable.appendLine("<rss version=\"2.0\">")
+               appendable.appendLine("<channel>")
+               
+               appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
+               
+               if (language != null)
+                       appendable.append("<language>").append(language.escapeHTML()).appendLine("</language>")
+               if (copyright != null)
+                       appendable.append("<copyright>").append(copyright.escapeHTML()).appendLine("</copyright>")
+               if (managingEditor != null)
+                       appendable.append("<managingEditor>").append(managingEditor.escapeHTML()).appendLine("</managingEditor>")
+               if (webMaster != null)
+                       appendable.append("<webMaster>").append(webMaster.escapeHTML()).appendLine("</webMaster>")
+               if (pubDate != null)
+                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
+               if (lastBuildDate != null)
+                       appendable.append("<lastBuildDate>").append(lastBuildDate.toXml()).appendLine("</lastBuildDate>")
+               if (ttl != null)
+                       appendable.append("<ttl>").append(ttl.toString()).appendLine("</ttl>")
+               
+               image?.toXml(appendable)
+               
+               for (category in categories)
+                       category.toXml(appendable)
+               for (item in items)
+                       item.toXml(appendable)
+               
+               appendable.appendLine("</channel>")
+               appendable.appendLine("</rss>")
+       }
+}
+
+data class RssItemEnclosure(
+       val url: String,
+       val length: Long,
+       val type: String,
+) {
+       fun toXml(appendable: Appendable) {
+               appendable.append("<enclosure ")
+                       .append("url=\"").append(url.escapeHTML()).append("\" ")
+                       .append("length=\"").append(length.toString()).append("\" ")
+                       .append("type=\"").append(type.escapeHTML()).append("\" ")
+                       .appendLine("/>")
+       }
+}
+
+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<RssCategory> = emptyList(),
+) {
+       init {
+               require(title != null || description != null) { "Either title or description must be provided, got null for both" }
+       }
+       
+       fun toXml(appendable: Appendable) {
+               appendable.appendLine("<item>")
+               
+               if (title != null)
+                       appendable.append("<title>").append(title.escapeHTML()).appendLine("</title>")
+               if (description != null)
+                       appendable.append("<description>").append(description.escapeHTML()).appendLine("</description>")
+               if (link != null)
+                       appendable.append("<link>").append(link.escapeHTML()).appendLine("</link>")
+               if (author != null)
+                       appendable.append("<author>").append(author.escapeHTML()).appendLine("</author>")
+               if (comments != null)
+                       appendable.append("<comments>").append(comments.escapeHTML()).appendLine("</comments>")
+               enclosure?.toXml(appendable)
+               
+               if (pubDate != null)
+                       appendable.append("<pubDate>").append(pubDate.toXml()).appendLine("</pubDate>")
+               
+               for (category in categories)
+                       category.toXml(appendable)
+               
+               appendable.appendLine("</item>")
+       }
+}
index 24497e4b27fefce313d4e7184332b7e08d978fcc..d53625d2088e215793270f2bb0081fd09facbd69 100644 (file)
@@ -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;