Fix VFS and page titling
authorLanius Trolling <lanius@laniustrolling.dev>
Fri, 12 Apr 2024 00:32:33 +0000 (20:32 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Fri, 12 Apr 2024 00:32:33 +0000 (20:32 -0400)
src/jvmMain/kotlin/info/mechyrdia/data/data_files.kt
src/jvmMain/kotlin/info/mechyrdia/data/view_comments.kt
src/jvmMain/kotlin/info/mechyrdia/data/views_files.kt
src/jvmMain/kotlin/info/mechyrdia/lore/article_listing.kt
src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/view_nav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/views_lore.kt

index 48c35de2acb956c60c5477b57d50ab078ed4eeee..389132785a79c5475b81036440b8570c0aec2785 100644 (file)
@@ -34,12 +34,10 @@ suspend fun ApplicationCall.respondStoredFile(fileStorage: FileStorage, path: St
        
        val extension = path.elements.last().substringAfter('.', "")
        val type = if (extension.isEmpty()) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
-       val result = fileStorage.readFile(path) { producer ->
-               attributes.put(StoragePathAttributeKey, path)
-               respondBytesWriter(contentType = type, contentLength = stat.size, producer = producer)
-       }
+       val content = fileStorage.readFile(path) ?: return respond(HttpStatusCode.NotFound)
        
-       if (!result) respond(HttpStatusCode.NotFound)
+       attributes.put(StoragePathAttributeKey, path)
+       respondBytes(content, type)
 }
 
 suspend fun ApplicationCall.respondStoredFile(path: StoragePath) {
@@ -107,12 +105,8 @@ interface FileStorage {
        
        suspend fun statFile(path: StoragePath): StoredFileStats?
        
-       suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean
-       
        suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean
        
-       suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean
-       
        suspend fun readFile(path: StoragePath): ByteArray?
        
        suspend fun copyFile(source: StoragePath, target: StoragePath): Boolean
@@ -225,17 +219,6 @@ private class FlatFileStorage(val root: File) : FileStorage {
                return StoredFileStats(Instant.ofEpochMilli(file.lastModified()), file.length())
        }
        
-       override suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean {
-               val file = resolveFile(path)
-               
-               return withContext(Dispatchers.IO) {
-                       if (createFile(file)) {
-                               file.writeChannel().use { content().copyTo(this) }
-                               true
-                       } else false
-               }
-       }
-       
        override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
                val file = resolveFile(path)
                
@@ -247,17 +230,6 @@ private class FlatFileStorage(val root: File) : FileStorage {
                }
        }
        
-       override suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean {
-               val file = resolveFile(path)
-               if (!file.isFile) return false
-               
-               content {
-                       file.readChannel().copyTo(this)
-               }
-               
-               return true
-       }
-       
        override suspend fun readFile(path: StoragePath): ByteArray? {
                val file = resolveFile(path)
                if (!file.isFile) return null
@@ -422,23 +394,6 @@ private class GridFsStorage(val table: DocumentTable<GridFsEntry>, val bucket: G
                return StoredFileStats(file.updated, gridFsFile.length)
        }
        
-       override suspend fun writeFile(path: StoragePath, content: () -> ByteReadChannel): Boolean {
-               if (path.isRoot) return false
-               if (getSuffix(path) != null) return false
-               if (countPrefix(path) > 0) return false
-               
-               val bytesPublisher = flow {
-                       content().consumeEachBufferRange { buffer, last ->
-                               emit(buffer.copy())
-                               !last
-                       }
-               }.asPublisher(CoroutineName("grid-fs-writer") + Dispatchers.IO)
-               
-               val newId = bucket.uploadFromPublisher(path.elements.last(), bytesPublisher).awaitFirst()
-               updateExact(path, newId)
-               return true
-       }
-       
        override suspend fun writeFile(path: StoragePath, content: ByteArray): Boolean {
                if (path.isRoot) return false
                if (getSuffix(path) != null) return false
@@ -453,20 +408,6 @@ private class GridFsStorage(val table: DocumentTable<GridFsEntry>, val bucket: G
                return true
        }
        
-       override suspend fun readFile(path: StoragePath, content: suspend (suspend ByteWriteChannel.() -> Unit) -> Unit): Boolean {
-               if (path.isRoot) return false
-               val file = getExact(path) ?: return false
-               val gridFsId = file.file
-               
-               content {
-                       bucket.downloadToPublisher(gridFsId).asFlow().collect { buffer ->
-                               writeFully(buffer)
-                       }
-               }
-               
-               return true
-       }
-       
        override suspend fun readFile(path: StoragePath): ByteArray? {
                if (path.isRoot) return null
                val file = getExact(path) ?: return null
index 3c86b79167fd62406431244da16252ffddfad030..09e1e03888e59c32bb2803773943a77e3dd84210 100644 (file)
@@ -19,6 +19,8 @@ data class CommentRenderData(
        val submittedIn: List<String>,
        val submittedAt: Instant,
        
+       val submittedInTitle: String,
+       
        val numEdits: Int,
        val lastEdit: Instant?,
        
@@ -32,14 +34,16 @@ data class CommentRenderData(
                        return coroutineScope {
                                comments.map { comment ->
                                        async {
-                                               val nationData = nations.getNation(comment.submittedBy)
+                                               val nationDataAsync = async { nations.getNation(comment.submittedBy) }
+                                               val pageTitleAsync = async { (StoragePath.articleDir / comment.submittedIn.split('/')).toFriendlyPathTitle() }
                                                val htmlResult = comment.contents.parseAs(ParserTree::toCommentHtml)
                                                
                                                CommentRenderData(
                                                        id = comment.id,
-                                                       submittedBy = nationData,
+                                                       submittedBy = nationDataAsync.await(),
                                                        submittedIn = comment.submittedIn.split('/'),
                                                        submittedAt = comment.submittedAt,
+                                                       submittedInTitle = pageTitleAsync.await(),
                                                        numEdits = comment.numEdits,
                                                        lastEdit = comment.lastEdit,
                                                        contentsRaw = comment.contents,
@@ -63,7 +67,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                        style = "font-size:1.5em;margin-top:2.5em"
                        +"On factbook "
                        a(href = href(Root.LorePage(comment.submittedIn))) {
-                               +comment.submittedIn.toFriendlyIndexTitle()
+                               +comment.submittedInTitle
                        }
                }
        
index 779ac89f200630cdf7ced32ebd9d4ba4780447c8..3c672a93fad78ce93033a0efc100361d9c55d942 100644 (file)
@@ -14,10 +14,7 @@ import io.ktor.server.html.*
 import io.ktor.server.plugins.*
 import io.ktor.server.response.*
 import io.ktor.utils.io.jvm.javaio.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.*
 import kotlinx.html.*
 
 private sealed class TreeNode {
@@ -219,15 +216,11 @@ private val textExtensions = listOf(
 
 suspend fun ApplicationCall.adminPreviewFile(path: StoragePath) {
        attributes.put(PageDoNotCacheAttributeKey, true)
-       val stat = FileStorage.instance.statFile(path) ?: return respond(HttpStatusCode.NotFound)
        
        val extension = path.elements.last().substringAfterLast('.', "")
        val type = if (extension in textExtensions) ContentType.Text.Plain else ContentType.defaultForFileExtension(extension)
-       val result = FileStorage.instance.readFile(path) { producer ->
-               respondBytesWriter(contentType = type, contentLength = stat.size, producer = producer)
-       }
-       
-       if (!result) respond(HttpStatusCode.NotFound)
+       val result = FileStorage.instance.readFile(path) ?: return respond(HttpStatusCode.NotFound)
+       respondBytes(result, type)
 }
 
 private suspend fun fileTreeForCopy(path: StoragePath): TreeNode.DirNode? {
@@ -294,14 +287,15 @@ suspend fun ApplicationCall.adminUploadFile(path: StoragePath, part: PartData.Fi
        val name = part.originalFileName ?: throw MissingRequestParameterException("originalFileName")
        val filePath = path / name
        
-       if (FileStorage.instance.writeFile(filePath) { part.streamProvider().toByteReadChannel(Dispatchers.IO) })
+       val content = withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }
+       if (FileStorage.instance.writeFile(filePath, content))
                redirectHref(Root.Admin.Vfs.View(filePath.elements))
        else
                respond(HttpStatusCode.Conflict)
 }
 
 suspend fun ApplicationCall.adminOverwriteFile(path: StoragePath, part: PartData.FileItem) {
-       if (FileStorage.instance.writeFile(path) { part.streamProvider().toByteReadChannel(Dispatchers.IO) })
+       if (FileStorage.instance.writeFile(path, withContext(Dispatchers.IO) { part.streamProvider().readAllBytes() }))
                redirectHref(Root.Admin.Vfs.View(path.elements))
        else
                respond(HttpStatusCode.Conflict)
index 237b2492af327f46c20bba79c107245376607775..c036473647e11d66941bb34ddaa497450b70a4ea 100644 (file)
@@ -14,12 +14,13 @@ import kotlinx.html.a
 import kotlinx.html.li
 import kotlinx.html.ul
 
-data class ArticleNode(val name: String, val subNodes: List<ArticleNode>)
+data class ArticleNode(val name: String, val title: String, val subNodes: List<ArticleNode>)
 
 suspend fun rootArticleNodeList(): List<ArticleNode> = StoragePath.articleDir.toArticleNode().subNodes
 
 suspend fun StoragePath.toArticleNode(): ArticleNode = ArticleNode(
        name,
+       toFriendlyPageTitle(),
        coroutineScope {
                val path = this@toArticleNode
                FileStorage.instance.listDir(path)?.map {
@@ -44,7 +45,7 @@ fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), for
                if (node.isViewable)
                        list.li {
                                val nodePath = base + node.name
-                               a(href = href(Root.LorePage(nodePath, format))) { +node.name.toFriendlyPageTitle() }
+                               a(href = href(Root.LorePage(nodePath, format))) { +node.title }
                                if (node.subNodes.isNotEmpty())
                                        ul {
                                                node.subNodes.renderInto(this, nodePath, format)
@@ -53,8 +54,20 @@ fun List<ArticleNode>.renderInto(list: UL, base: List<String> = emptyList(), for
        }
 }
 
-fun List<String>.toFriendlyIndexTitle() = joinToString(separator = " - ") { part ->
-       part.toFriendlyPageTitle()
+suspend fun StoragePath.toFriendlyPageTitle() = ArticleTitleCache.get(this)
+       ?: if (elements.size > 1)
+               elements.lastOrNull()?.split('-')?.joinToString(separator = " ") { word ->
+                       word.lowercase().replaceFirstChar { it.titlecase() }
+               }.orEmpty()
+       else TOC_TITLE
+
+suspend fun StoragePath.toFriendlyPathTitle(): String {
+       val lorePath = elements.drop(1)
+       if (lorePath.isEmpty()) return TOC_TITLE
+       
+       return lorePath.indices.drop(1).map { index ->
+               StoragePath(lorePath.take(index)).toFriendlyPageTitle()
+       }.joinToString(separator = " - ")
 }
 
 fun String.toFriendlyPageTitle() = split('-').joinToString(separator = " ") { word ->
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/article_titles.kt
new file mode 100644 (file)
index 0000000..2e0ac9c
--- /dev/null
@@ -0,0 +1,21 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.FileStorage
+import info.mechyrdia.data.StoragePath
+
+object ArticleTitleCache : FileDependentCache<String>() {
+       override suspend fun processFile(path: StoragePath): String? {
+               if (path.elements[0] != StoragePath.articleDir.elements[0])
+                       return null
+               
+               val bytes = FileStorage.instance.readFile(path) ?: return null
+               val text = String(bytes)
+               
+               return ParserState
+                       .parseText(text)
+                       .filterIsInstance<ParserTreeNode.Tag>()
+                       .first { it isTag "h1" }
+                       .subNodes
+                       .treeToText()
+       }
+}
index 61d7479955a3e5c5f01c9c19b2dfc2c3e162495c..52624039f03d2e1dcb5afa3f3e172091344ff264 100644 (file)
@@ -35,9 +35,9 @@ suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
        NavLink(href(Root()), "Lore Intro"),
        NavLink(href(Root.LorePage(emptyList())), TOC_TITLE),
 ) + path?.let { pathParts ->
-       pathParts.dropLast(1).mapIndexed { i, part ->
-               val subPath = pathParts.take(i + 1)
-               NavLink(href(Root.LorePage(subPath)), part.toFriendlyPageTitle())
+       pathParts.drop(1).indices.map { i ->
+               val subPath = pathParts.take(i)
+               NavLink(href(Root.LorePage(subPath)), (StoragePath.articleDir / subPath).toFriendlyPageTitle())
        }
 }.orEmpty() + (currentNation()?.let { data ->
        listOf(
index 9aa96aa28446e094b3a3dfe347ff008e58f9074b..56098fe0d9d8becd33e5299993381e6b13b88bc7 100644 (file)
@@ -62,17 +62,16 @@ enum class LoreArticleFormat(val format: String? = null) {
 object LoreArticleFormatSerializer : KeyedEnumSerializer<LoreArticleFormat>(LoreArticleFormat.entries, LoreArticleFormat::format)
 
 suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.() -> Unit {
-       val articleDir = StoragePath.articleDir
-       
-       val pageFile = articleDir / pagePath
+       val pageFile = StoragePath.articleDir / pagePath
        val pageNode = pageFile.toArticleNode()
        
        val parentPaths = if (pagePath.isEmpty())
                emptyList()
        else {
-               val pathDirs = pagePath.dropLast(1)
-               listOf(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML) to TOC_TITLE) + pathDirs.mapIndexed { i, part ->
-                       Root.LorePage(pathDirs.take(i + 1), LoreArticleFormat.RAW_HTML) to part.toFriendlyPageTitle()
+               val pathDirs = pagePath.drop(1)
+               listOf(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML) to TOC_TITLE) + pathDirs.indices.map { i ->
+                       val prefixPath = pathDirs.take(i)
+                       Root.LorePage(prefixPath, LoreArticleFormat.RAW_HTML) to (StoragePath.articleDir / prefixPath).toFriendlyPageTitle()
                }
        }
        
@@ -81,11 +80,9 @@ suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.()
        
        if (isValid) {
                if (pageType == StoredFileType.DIRECTORY) {
-                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: TOC_TITLE
-                       
-                       return rawPage(title) {
+                       return rawPage(pageNode.title) {
                                breadCrumbs(parentPaths)
-                               h1 { +title }
+                               h1 { +pageNode.title }
                                ul {
                                        pageNode.subNodes.renderInto(this, pagePath, LoreArticleFormat.RAW_HTML)
                                }
@@ -106,11 +103,9 @@ suspend fun ApplicationCall.loreRawArticlePage(pagePath: List<String>): HTML.()
                }
        }
        
-       val title = pagePath.last().toFriendlyPageTitle()
-       
-       return rawPage(title) {
+       return rawPage(pageNode.title) {
                breadCrumbs(parentPaths)
-               h1 { +title }
+               h1 { +pageNode.title }
                p {
                        +"This factbook does not exist. Would you like to "
                        a(href = href(Root.LorePage(emptyList(), LoreArticleFormat.RAW_HTML))) { +"return to the table of contents" }
@@ -144,19 +139,17 @@ suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: Lore
                if (pageType == StoredFileType.DIRECTORY) {
                        val navbar = standardNavBar(pagePath.takeIf { it.isNotEmpty() })
                        
-                       val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: TOC_TITLE
-                       
                        val sidebar = PageNavSidebar(
                                listOf(
-                                       NavLink("#page-top", title, aClasses = "left"),
+                                       NavLink("#page-top", pageNode.title, aClasses = "left"),
                                        NavLink("#comments", "Comments", aClasses = "left")
                                )
                        )
                        
-                       return page(title, navbar, sidebar) {
+                       return page(pageNode.title, navbar, sidebar) {
                                section {
                                        a { id = "page-top" }
-                                       h1 { +title }
+                                       h1 { +pageNode.title }
                                        ul {
                                                pageNode.subNodes.renderInto(this, pagePath, format = format)
                                        }
@@ -186,7 +179,7 @@ suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: Lore
                }
        }
        
-       val title = pagePath.last().toFriendlyPageTitle()
+       val title = pageNode.title
        val navbar = standardNavBar(pagePath)
        val sidebar = PageNavSidebar(
                listOf(
@@ -198,7 +191,7 @@ suspend fun ApplicationCall.loreArticlePage(pagePath: List<String>, format: Lore
        return page(title, navbar, sidebar) {
                section {
                        a { id = "page-top" }
-                       h1 { +title }
+                       h1 { +pageNode.title }
                        p {
                                +"This factbook does not exist. Would you like to "
                                a(href = href(Root())) { +"return to the index page" }