From 69a43e62521b184b4e9cf3e6d23292837ee49e72 Mon Sep 17 00:00:00 2001 From: Stefan Schallerl Date: Wed, 5 Feb 2025 17:14:45 +0100 Subject: [PATCH] Intermittent commit * Adds document editing * Adds human readable byte size formatting * Adds file downloading * Uses extIds instead of ids in some places. --- app/build.gradle.kts | 13 +- .../kotlin/net/h34t/filemure/DateGuesser.kt | 25 +- .../main/kotlin/net/h34t/filemure/Entities.kt | 12 - .../kotlin/net/h34t/filemure/FilemureApp.kt | 7 +- .../main/kotlin/net/h34t/filemure/Server.kt | 1 + .../net/h34t/filemure/TemplateModifiers.kt | 4 +- app/src/main/kotlin/net/h34t/filemure/Util.kt | 10 +- .../filemure/controller/DocumentController.kt | 156 +++++-- .../filemure/controller/LimboController.kt | 6 +- .../filemure/repository/SqliteRepository.kt | 379 ++++++++++-------- .../create_db.sql => sqldelight/database.sql} | 16 +- .../net.h34t.filemure.tpl/Document.tpl.html | 8 +- ...m.tpl.html => DocumentCreateForm.tpl.html} | 10 +- .../DocumentEditForm.tpl.html | 20 + .../net.h34t.filemure.tpl/FileList.tpl.html | 24 ++ .../FilePreview.tpl.html | 9 + .../tpl/net.h34t.filemure.tpl/Limbo.tpl.html | 2 +- .../net/h34t/filemure/DateGuesserTest.kt | 36 ++ .../net/h34t/filemure/core/entity/Types.kt | 22 + public/filemure.css | 15 + 20 files changed, 540 insertions(+), 235 deletions(-) delete mode 100644 app/src/main/kotlin/net/h34t/filemure/Entities.kt rename app/src/main/{resources/create_db.sql => sqldelight/database.sql} (73%) rename app/src/main/tpl/net.h34t.filemure.tpl/{NewDocumentForm.tpl.html => DocumentCreateForm.tpl.html} (78%) create mode 100644 app/src/main/tpl/net.h34t.filemure.tpl/DocumentEditForm.tpl.html create mode 100644 app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html create mode 100644 app/src/main/tpl/net.h34t.filemure.tpl/FilePreview.tpl.html create mode 100644 app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f880791..4d224c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,12 +3,14 @@ plugins { // The shared code is located in `buildSrc/src/main/kotlin/kotlin-jvm.gradle.kts`. id("buildsrc.convention.kotlin-jvm") alias(libs.plugins.tempolin) + id("app.cash.sqldelight") version "2.0.2" // Apply the Application plugin to add support for building an executable JVM application. application } dependencies { + implementation("app.cash.sqldelight:sqlite-driver:2.0.2") implementation("org.xerial:sqlite-jdbc:3.48.0.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.+") @@ -16,6 +18,7 @@ dependencies { implementation(libs.javalin) implementation(libs.commonsText) implementation(project(":core")) + testImplementation(kotlin("test")) } application { @@ -58,4 +61,12 @@ tempolin { templateInterface = "net.h34t.filemure.Template" } -} \ No newline at end of file +} + +sqldelight { + databases { + create("Database") { + packageName.set("net.h34t.filemure.db") + } + } +} diff --git a/app/src/main/kotlin/net/h34t/filemure/DateGuesser.kt b/app/src/main/kotlin/net/h34t/filemure/DateGuesser.kt index eebfdae..2e5fc0b 100644 --- a/app/src/main/kotlin/net/h34t/filemure/DateGuesser.kt +++ b/app/src/main/kotlin/net/h34t/filemure/DateGuesser.kt @@ -3,6 +3,7 @@ package net.h34t.filemure import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException object DateGuesser { @@ -27,8 +28,8 @@ object DateGuesser { Regex("\\d{4}\\d{2}\\d{2} \\d{2}\\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMdd' 'HHmmss"), Regex("\\d{4}\\d{2}\\d{2} \\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMdd' 'HHmm"), - Regex("\\d{4}\\d{2}\\d{2}\\d{2}\\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMdd' 'HHmmss"), - Regex("\\d{4}\\d{2}\\d{2}\\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMdd' 'HHmm"), + Regex("\\d{4}\\d{2}\\d{2}\\d{2}\\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMddHHmmss"), + Regex("\\d{4}\\d{2}\\d{2}\\d{2}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMddHHmm"), ) private val datePatterns = listOf( @@ -40,12 +41,24 @@ object DateGuesser { ) - fun guessDateTime(filename: String) = dateTimePatterns.asSequence().mapNotNull { - it.first.find(filename)?.let { mr -> LocalDateTime.parse(mr.groups[0]?.value!!, it.second) } + private fun guessDateTime(filename: String) = dateTimePatterns.asSequence().map { + it.first.findAll(filename).mapNotNull { mr -> + try { + LocalDateTime.parse(mr.groups[0]?.value!!, it.second) + } catch (e: DateTimeParseException) { + null + } + }.firstOrNull() }.firstOrNull() - fun guessDate(filename: String) = datePatterns.asSequence().mapNotNull { - it.first.find(filename)?.let { mr -> LocalDate.parse(mr.groups[0]?.value!!, it.second) } + private fun guessDate(filename: String) = datePatterns.asSequence().map { + it.first.findAll(filename).mapNotNull { mr -> + try { + LocalDate.parse(mr.groups[0]?.value!!, it.second) + } catch (e: DateTimeParseException) { + null + } + }.firstOrNull() }.firstOrNull() fun guess(filename: String): LocalDateTime? { diff --git a/app/src/main/kotlin/net/h34t/filemure/Entities.kt b/app/src/main/kotlin/net/h34t/filemure/Entities.kt deleted file mode 100644 index 939b66c..0000000 --- a/app/src/main/kotlin/net/h34t/filemure/Entities.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.h34t.filemure - -import java.time.LocalDateTime - -data class LimboFile( - val id: Long, - val accountId: Long, - val filename: String, - val contentType: String, - val size: Long, - val created: LocalDateTime, - ) \ No newline at end of file diff --git a/app/src/main/kotlin/net/h34t/filemure/FilemureApp.kt b/app/src/main/kotlin/net/h34t/filemure/FilemureApp.kt index 0c7cd63..0d7b70e 100644 --- a/app/src/main/kotlin/net/h34t/filemure/FilemureApp.kt +++ b/app/src/main/kotlin/net/h34t/filemure/FilemureApp.kt @@ -20,8 +20,9 @@ class FilemureApp(repository: SqliteRepository) { fun register(server: Javalin) { server.beforeMatched { ctx -> + println(ctx.req().pathInfo) val userRole = getUserRole(ctx) - if (!ctx.routeRoles().contains(userRole)) { + if (ctx.routeRoles().isNotEmpty() && !ctx.routeRoles().contains(userRole)) { ctx.redirectPRG("/login") throw UnauthorizedResponse() } @@ -38,7 +39,11 @@ class FilemureApp(repository: SqliteRepository) { server.get("/document/new", documentController::createDocumentForm, Role.USER) server.post("/document/new", documentController::createDocument, Role.USER) server.get("/document/{extId}", documentController::documentDetail, Role.USER) + server.get("/document/{extId}/download", documentController::downloadDocument, Role.USER) + server.get("/document/{extId}/edit", documentController::editDocumentForm, Role.USER) + server.post("/document/{extId}/edit", documentController::editDocumentAction, Role.USER) + server.get("/file/{extId}/download", documentController::downloadFile, Role.USER) server.exception(UnauthorizedResponse::class.java) { e, ctx -> ctx.tempolin( diff --git a/app/src/main/kotlin/net/h34t/filemure/Server.kt b/app/src/main/kotlin/net/h34t/filemure/Server.kt index 3db3f73..216745c 100644 --- a/app/src/main/kotlin/net/h34t/filemure/Server.kt +++ b/app/src/main/kotlin/net/h34t/filemure/Server.kt @@ -18,6 +18,7 @@ fun main() { staticFiles.hostedPath = "/" staticFiles.directory = "./public" staticFiles.location = Location.EXTERNAL + // staticFiles. } config.requestLogger.http { ctx, ms -> diff --git a/app/src/main/kotlin/net/h34t/filemure/TemplateModifiers.kt b/app/src/main/kotlin/net/h34t/filemure/TemplateModifiers.kt index 08f9dcf..3ac608c 100644 --- a/app/src/main/kotlin/net/h34t/filemure/TemplateModifiers.kt +++ b/app/src/main/kotlin/net/h34t/filemure/TemplateModifiers.kt @@ -4,8 +4,8 @@ import net.h34t.filemure.tpl.* import org.apache.commons.text.StringEscapeUtils import java.net.URLEncoder -class TemplateModifiers : Frame.Modifiers, Limbo.Modifiers, NewDocumentForm.Modifiers, Overview.Modifiers, - Document.Modifiers { +class TemplateModifiers : Frame.Modifiers, Limbo.Modifiers, DocumentCreateForm.Modifiers, Overview.Modifiers, + Document.Modifiers, FilePreview.Modifiers, DocumentEditForm.Modifiers, FileList.Modifiers { fun hashPrefix(arg: String): String { return URLEncoder.encode(arg, Charsets.UTF_8) diff --git a/app/src/main/kotlin/net/h34t/filemure/Util.kt b/app/src/main/kotlin/net/h34t/filemure/Util.kt index 021956e..0d5c10e 100644 --- a/app/src/main/kotlin/net/h34t/filemure/Util.kt +++ b/app/src/main/kotlin/net/h34t/filemure/Util.kt @@ -48,4 +48,12 @@ value class ExtId(val value: String) { return ExtId((0..8).map { chars.random() }.joinToString("")) } } -} \ No newline at end of file +} + +fun formatHumanReadableSize(bytes: Long) = when (bytes) { + in 0L..<1024L -> "$bytes bytes" + in 1025..<1024 * 1_000 -> "${bytes / 1000} kb" + in 1025 * 1_000..<1024 * 1_000_000 -> "${bytes / 1_000_000} mb" + else -> "${bytes / 1_000_000_000} gb" +} + diff --git a/app/src/main/kotlin/net/h34t/filemure/controller/DocumentController.kt b/app/src/main/kotlin/net/h34t/filemure/controller/DocumentController.kt index f228f5e..fc57d96 100644 --- a/app/src/main/kotlin/net/h34t/filemure/controller/DocumentController.kt +++ b/app/src/main/kotlin/net/h34t/filemure/controller/DocumentController.kt @@ -3,11 +3,11 @@ package net.h34t.filemure.controller import io.javalin.http.BadRequestResponse import io.javalin.http.Context import io.javalin.http.ForbiddenResponse +import io.javalin.http.Header import net.h34t.filemure.* +import net.h34t.filemure.core.entity.Tag import net.h34t.filemure.repository.SqliteRepository -import net.h34t.filemure.tpl.Document -import net.h34t.filemure.tpl.Frame -import net.h34t.filemure.tpl.NewDocumentForm +import net.h34t.filemure.tpl.* import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -16,7 +16,7 @@ import java.time.format.FormatStyle class DocumentController(val modifiers: TemplateModifiers, val repository: SqliteRepository) { private val dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) - private val isoDtf = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + private val formDtf = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") fun documentDetail(ctx: Context) { val session = ctx.requireSession() @@ -31,19 +31,25 @@ class DocumentController(val modifiers: TemplateModifiers, val repository: Sqlit isTarget = true, content = Document( modifiers = modifiers, + extId = document.extId, title = document.title, referenceDate = dtf.format(document.referenceDate), tags = { document.tags.map { TagsBlock(tag = it.value) }.asSequence() }, description = document.description, - files = { - document.files.map { file -> - FilesBlock( - extId = file.extId, - filename = file.filename, - contentType = file.contentType ?: "?" - ) - }.asSequence() - }, + files = FileList( + modifiers = modifiers, + delete = true, + files = { + document.files.map { file -> + FilesBlock( + extId = file.extId, + filename = file.filename, + contentType = file.contentType ?: "?", + size = formatHumanReadableSize(file.fileSize), + delete = true, + ) + }.asSequence() + }), ) ) ) @@ -53,42 +59,51 @@ class DocumentController(val modifiers: TemplateModifiers, val repository: Sqlit fun createDocumentForm(ctx: Context) { val session = ctx.requireSession() - val fileIds = ctx.queryParams("file_id").map { it.toLong() } + val fileIds = ctx.queryParams("file_id") val limboFiles = repository.getFilesInLimbo(session.id) - val limboFileIds = limboFiles.map { it.id } + val limboFileIds = limboFiles.map { it.extId } if (!fileIds.all { it in limboFileIds }) { throw ForbiddenResponse("Mismatched file ids.") } - val selectedFiles = limboFiles.filter { it.id in fileIds } + val selectedFiles = limboFiles.filter { it.extId in fileIds } - val title = "new document" - val referenceDate = selectedFiles.asSequence().mapNotNull { DateGuesser.guess(it.filename) }.firstOrNull() + val title = selectedFiles.firstOrNull()?.filename ?: "new document" + val referenceDates = selectedFiles.mapNotNull { DateGuesser.guess(it.filename) } + + val referenceDate = referenceDates.firstOrNull() ?: LocalDateTime.now() val tags = selectedFiles.map { File(it.filename).extension }.distinct().asSequence() val description = "" ctx.tempolin( Frame( - modifiers = modifiers, title = "new document", isTarget = true, content = NewDocumentForm( + modifiers = modifiers, + title = "new document", + isTarget = true, + content = DocumentCreateForm( modifiers = modifiers, title = title, - referenceDate = isoDtf.format(referenceDate), + referenceDate = formDtf.format(referenceDate), tags = { tags.map { TagsBlock(it) } }, description = description, - files = { - selectedFiles - .map { + files = FileList( + modifiers = modifiers, + delete = true, + files = { + selectedFiles.map { file -> FilesBlock( - id = it.id.toString(), - filename = it.filename, - contentType = it.contentType + extId = file.extId, + filename = file.filename, + contentType = file.contentType ?: "?", + size = formatHumanReadableSize(file.fileSize), + delete = true, ) - } - .asSequence() - }) + }.asSequence() + }), + ) ) ) } @@ -98,15 +113,92 @@ class DocumentController(val modifiers: TemplateModifiers, val repository: Sqlit val title = ctx.formParam("title") ?: throw BadRequestResponse("") - val referenceDate = ctx.formParam("reference_date")?.let { LocalDateTime.parse(it, isoDtf) } + val referenceDate = ctx.formParam("reference_date")?.let { LocalDateTime.parse(it, formDtf) } ?: throw BadRequestResponse("") val tags = ctx.formParam("tags")?.split("\\s+") ?: emptyList() val description = ctx.formParam("description") ?: throw BadRequestResponse("") - val fileIds = ctx.formParams("file_id").map { it.toLong() } + val fileExtIds = ctx.formParams("file_id") - val extId = repository.addDocument(session.id, title, referenceDate, tags, description, fileIds) + val extId = repository.addDocument(session.id, title, referenceDate, tags, description, fileExtIds) + + ctx.redirectPRG("/document/$extId") + } + + fun downloadDocument(ctx: Context) { + // TODO pack all files in a zip + } + + fun downloadFile(ctx: Context) { + val session = ctx.requireSession() + val extId = ctx.pathParam("extId") + + val file = repository.loadFile(accountId = session.id, extId = extId) + + file.contentType?.also { ctx.header(Header.CONTENT_TYPE, it) } + ctx.result(file.content) + } + + fun editDocumentForm(ctx: Context) { + val session = ctx.requireSession() + val extId = ctx.pathParam("extId") + + val document = repository.getDocumentByExtId(accountId = session.id, extId = extId) + + ctx.tempolin( + Frame( + modifiers = modifiers, + title = document.title, + isTarget = false, + content = DocumentEditForm( + modifiers = modifiers, + extId = extId, + title = document.title, + referenceDate = formDtf.format(document.referenceDate), + tags = { + document.tags.map { tag -> TagsBlock(tag = tag.value) }.asSequence() + }, + description = document.description, + files = FileList( + modifiers = modifiers, + delete = true, + files = { + document.files.map { file -> + FilesBlock( + extId = file.extId, + filename = file.filename, + contentType = file.contentType ?: "?", + size = formatHumanReadableSize(file.fileSize), + delete = true, + ) + }.asSequence() + }), + ) + ) + ) + } + + fun editDocumentAction(ctx: Context) { + val session = ctx.requireSession() + val extId = ctx.pathParam("extId") + + val document = repository.getDocumentByExtId(accountId = session.id, extId = extId) + + + val title = ctx.formParam("title") + val referenceDate = ctx.formParam("reference_date") + val description = ctx.formParam("description") ?: "" + val tags = ctx.formParam("tags") + + repository.updateDocument( + accountId = session.id, + id = document.id, + title = title ?: "", + referenceDate = LocalDateTime.parse(referenceDate ?: "", formDtf), + tags = Tag.parse(tags), + description = description + ) ctx.redirectPRG("/document/$extId") } diff --git a/app/src/main/kotlin/net/h34t/filemure/controller/LimboController.kt b/app/src/main/kotlin/net/h34t/filemure/controller/LimboController.kt index 7828a11..5ede23c 100644 --- a/app/src/main/kotlin/net/h34t/filemure/controller/LimboController.kt +++ b/app/src/main/kotlin/net/h34t/filemure/controller/LimboController.kt @@ -26,10 +26,10 @@ class LimboController(val modifiers: TemplateModifiers, val repository: SqliteRe modifiers = modifiers, limboFileCount = files.size.toString(), file = { files.map { f -> FileBlock( - id = f.id.toString(), + extId = f.extId, file = f.filename, - type = f.contentType, - size = f.size.toString(), + type = f.contentType ?: "", + size = f.fileSize.toString(), uploaded = f.created.format(dtf) ) }.asSequence() diff --git a/app/src/main/kotlin/net/h34t/filemure/repository/SqliteRepository.kt b/app/src/main/kotlin/net/h34t/filemure/repository/SqliteRepository.kt index 11118b6..419dcb7 100644 --- a/app/src/main/kotlin/net/h34t/filemure/repository/SqliteRepository.kt +++ b/app/src/main/kotlin/net/h34t/filemure/repository/SqliteRepository.kt @@ -1,10 +1,8 @@ package net.h34t.filemure.repository import net.h34t.filemure.ExtId -import net.h34t.filemure.LimboFile -import net.h34t.filemure.core.entity.Document -import net.h34t.filemure.core.entity.FileRef -import net.h34t.filemure.core.entity.Tag +import net.h34t.filemure.core.entity.* +import net.h34t.filemure.core.entity.Tag.Companion.serialize import java.io.InputStream import java.sql.Connection import java.sql.DriverManager @@ -33,37 +31,63 @@ class SqliteRepository(url: String) { } } - fun getLimboFileCount(accountId: Long): Long { - connection.prepareStatement("SELECT count(*) AS count FROM file WHERE account_id=? AND document_id IS NULL") + fun getLimboFileCount(accountId: Long, state: State = State.ACTIVE): Long { + connection.prepareStatement("SELECT count(*) AS count FROM file WHERE account_id=? AND document_id IS NULL AND state=?") .use { stmt -> stmt.setLong(1, accountId) + stmt.setInt(2, state.code) val rs = stmt.executeQuery() rs.next() return rs.getLong(1) } } - fun getFilesInLimbo(accountId: Long): List { - connection.prepareStatement("SELECT id, account_id, filename, content_type, file_size, created FROM file WHERE account_id=? AND document_id IS NULL ORDER BY created DESC") - .use { stmt -> - stmt.setLong(1, accountId) - val res = stmt.executeQuery() + fun getFilesInLimbo(accountId: Long, state: State = State.ACTIVE): List { + connection.prepareStatement( + """ + |SELECT + | id, + | ext_id, + | account_id, + | filename, + | content_type, + | content_extracted, + | file_size, + | created, + | state + |FROM + | file + |WHERE + | account_id=? AND + | document_id IS NULL + | AND state=? + |ORDER BY + | created DESC + """.trimMargin() + ).use { stmt -> + stmt.setLong(1, accountId) + stmt.setInt(2, state.code) + val res = stmt.executeQuery() - val list = mutableListOf() + val list = mutableListOf() - while (res.next()) { - list += LimboFile( - id = res.getLong(1), - accountId = res.getLong(2), - filename = res.getString(3), - contentType = res.getString(4), - size = res.getLong(5), - created = LocalDateTime.parse(res.getString(6), dtf) - ) - } - - return list + while (res.next()) { + list += FileRef( + id = res.getLong("id"), + extId = res.getString("ext_id"), + accountId = res.getLong("account_id"), + documentId = null, + filename = res.getString("filename"), + contentType = res.getString("content_type"), + contentExtracted = res.getString("content_extracted"), + fileSize = res.getLong("file_size"), + created = LocalDateTime.parse(res.getString("created"), dtf), + state = State.fromCode(res.getInt("state")) + ) } + + return list + } } fun addDocument( @@ -72,7 +96,7 @@ class SqliteRepository(url: String) { referenceDate: LocalDateTime, tags: List, description: String, - fileIds: List + fileExtIds: List ): ExtId { val savePoint = connection.setSavepoint() @@ -97,14 +121,14 @@ class SqliteRepository(url: String) { gks.getLong(1) } - val ids = fileIds.joinToString(",") + val extIds = fileExtIds.joinToString(",") { "'$it'" } - connection.prepareStatement("""UPDATE file SET document_id=? WHERE account_id=? AND id IN ($ids)""") + connection.prepareStatement("""UPDATE file SET document_id=? WHERE account_id=? AND ext_id IN ($extIds)""") .use { stmt -> stmt.setLong(1, documentId) stmt.setLong(2, accountId) val affected = stmt.executeUpdate() - require(affected == fileIds.size) + require(affected == fileExtIds.size) } connection.commit() @@ -115,131 +139,124 @@ class SqliteRepository(url: String) { } } - fun getDocuments(accountId: Long): List { - connection.prepareStatement("""SELECT d.id, d.account_id, d.ext_id, d.title, d.description, d.tags, d.created, d.reference_date FROM document d WHERE account_id=?""") - .use { stmt -> - stmt.setLong(1, accountId) - val res = stmt.executeQuery() + fun getDocuments(accountId: Long, state: State = State.ACTIVE): List { + connection.prepareStatement( + """ + |SELECT + | d.id, + | d.account_id, + | d.ext_id, + | d.title, + | d.description, + | d.tags, + | d.created, + | d.reference_date, + | d.state + |FROM + | document d + |WHERE + | account_id=? AND + | state=? + """.trimMargin() + ).use { stmt -> + stmt.setLong(1, accountId) + stmt.setInt(2, state.code) + val res = stmt.executeQuery() - val documentList = mutableListOf() + val documentList = mutableListOf() - while (res.next()) { - documentList.add( - Document( - id = res.getLong(1), - extId = res.getString(3), - title = res.getString(4), - description = res.getString(5), - tags = res.getString(6) - ?.let { if (it.isNotBlank()) it.split(",").map { tag -> Tag(tag) } else emptyList() } - ?: emptyList(), - created = LocalDateTime.parse(res.getString(7), dtf), - referenceDate = LocalDateTime.parse(res.getString(8), dtf), - files = emptyList() - ) + while (res.next()) { + documentList.add( + Document( + id = res.getLong("id"), + extId = res.getString("ext_id"), + title = res.getString("title"), + description = res.getString("description"), + tags = res.getString("tags") + ?.let { if (it.isNotBlank()) it.split(",").map { tag -> Tag(tag) } else emptyList() } + ?: emptyList(), + created = LocalDateTime.parse(res.getString("created"), dtf), + referenceDate = LocalDateTime.parse(res.getString("reference_date"), dtf), + state = State.fromCode(res.getInt("state")), + files = emptyList() ) - } - - - return documentList.toList() + ) } + + + return documentList.toList() + } } - fun getDocumentById(accountId: Long, id: Long): Document { - return connection.prepareStatement( + fun updateDocument( + accountId: Long, + id: Long, + title: String, + referenceDate: LocalDateTime, + tags: List, + description: String, + state: State = State.ACTIVE, + ) { + connection.prepareStatement( """ - SELECT - d.id, - d.account_id, - d.ext_id, - d.title, - d.description, - d.tags, - d.created, - d.reference_date, - f.id, - f.ext_id, - f.filename, - f.content_type, - f.content_extracted, - f.file_size - f.created - FROM - document d LEFT OUTER JOIN file f ON (d.id = f.document_id) - WHERE - id=? AND - account_id=? - """.trimIndent() - ) - .use { stmt -> - stmt.setLong(1, id) - stmt.setLong(2, accountId) - val res = stmt.executeQuery() + |UPDATE + | document + |SET + | title=?, + | reference_date=?, + | tags=?, + | description=?, + | state=? + |WHERE + | account_id=? AND + | id=? + """.trimMargin() + ).use { stmt -> + stmt.setString(1, title) + stmt.setString(2, dtf.format(referenceDate)) + stmt.setString(3, tags.serialize()) + stmt.setString(4, description) + stmt.setInt(5, state.code) + stmt.setLong(6, accountId) + stmt.setLong(7, id) - var document: Document? = null - val files = mutableListOf() - - while (res.next()) { - if (document == null) { - document = Document( - id = res.getLong(1), - extId = res.getString(3), - title = res.getString(4), - description = res.getString(5), - tags = res.getString(6).split(",").map { Tag(it) }, - created = LocalDateTime.parse(res.getString(7), dtf), - referenceDate = LocalDateTime.parse(res.getString(8), dtf), - files = emptyList() - ) - - val id = res.getLong(9) - if (!res.wasNull()) { - files.add( - FileRef( - id = id, - extId = res.getString(10), - filename = res.getString(11), - contentType = res.getString(12), - contentExtracted = res.getString(13), - fileSize = res.getLong(14), - created = LocalDateTime.parse(res.getString(15), dtf) - ) - ) - } - } - } - - document?.copy(files = files) ?: throw IllegalStateException("Document is null") - } + stmt.executeUpdate() + } } fun getDocumentByExtId(accountId: Long, extId: String): Document { return connection.prepareStatement( """ - SELECT - d.id, - d.account_id, - d.ext_id, - d.title, - d.description, - d.tags, - d.created, - d.reference_date, - f.id, - f.ext_id, - f.filename, - f.content_type, - f.content_extracted, - f.file_size, - f.created - FROM - document d LEFT OUTER JOIN file f ON (d.id = f.document_id) - WHERE - d.ext_id=? AND - d.account_id=? - """.trimIndent() + |SELECT + | d.id as d_id, + | d.account_id d_account_id, + | d.ext_id d_ext_id, + | d.title d_title, + | d.description d_description, + | d.tags d_tags, + | d.created d_created, + | d.reference_date d_reference_date, + | d.state as d_state, + | f.id f_id, + | f.ext_id f_ext_id, + | f.account_id f_account_id, + | f.document_id f_document_id, + | f.filename f_filename, + | f.content_type f_content_type, + | f.content_extracted f_content_extracted, + | f.file_size f_file_size, + | f.created f_created, + | f.state f_state + |FROM + | document d LEFT OUTER JOIN file f ON (d.id = f.document_id) + |WHERE + | d.ext_id=? AND + | d.account_id=? + """.trimMargin() ) .use { stmt -> + + stmt.setString(1, extId) stmt.setLong(2, accountId) val res = stmt.executeQuery() @@ -249,36 +266,76 @@ class SqliteRepository(url: String) { while (res.next()) { if (document == null) { document = Document( - id = res.getLong(1), - extId = res.getString(3), - title = res.getString(4), - description = res.getString(5), - tags = Tag.parse(res.getString(6)), - created = LocalDateTime.parse(res.getString(7), dtf), - referenceDate = LocalDateTime.parse(res.getString(8), dtf), + id = res.getLong("d_id"), + extId = res.getString("d_ext_id"), + title = res.getString("d_title"), + description = res.getString("d_description"), + tags = Tag.parse(res.getString("d_tags")), + created = LocalDateTime.parse(res.getString("d_created"), dtf), + referenceDate = LocalDateTime.parse(res.getString("d_reference_date"), dtf), + state = State.fromCode(res.getInt("d_state")), files = emptyList() ) - - val fid: Long = res.getLong(9) - - if (!res.wasNull()) { - files.add( - FileRef( - id = fid, - extId = res.getString(10), - filename = res.getString(11), - contentType = res.getString(12), - contentExtracted = res.getString(13), - fileSize = res.getLong(14), - created = LocalDateTime.parse(res.getString(15), dtf) - ) - ) - } } + + val fid: Long = res.getLong("f_id") + val wasNull = res.wasNull() + + if (!wasNull) { + files.add( + FileRef( + id = fid, + extId = res.getString("f_ext_id"), + accountId = res.getLong("f_account_id"), + documentId = res.getLong("f_document_id"), + filename = res.getString("f_filename"), + contentType = res.getString("f_content_type"), + contentExtracted = res.getString("f_content_extracted"), + fileSize = res.getLong("f_file_size"), + created = LocalDateTime.parse(res.getString("f_created"), dtf), + state = State.fromCode(res.getInt("f_state")) + ) + ) + } + } requireNotNull(document) document.copy(files = files) } } + + fun loadFile(accountId: Long, extId: String): FileContent { + return connection.prepareStatement( + """ + |SELECT + | id, + | ext_id, + | filename, + | content_type, + | content_extracted, + | file_size, + | content + |FROM + | file + |WHERE + | account_id=? AND + | ext_id=? + """.trimMargin() + ).use { stmt -> + stmt.setLong(1, accountId) + stmt.setString(2, extId) + val res = stmt.executeQuery() + + FileContent( + id = res.getLong(1), + extId = res.getString(2), + filename = res.getString(3), + contentType = res.getString(4), + contentExtracted = res.getString(5), + fileSize = res.getLong(6), + content = res.getBytes(7) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/resources/create_db.sql b/app/src/main/sqldelight/database.sql similarity index 73% rename from app/src/main/resources/create_db.sql rename to app/src/main/sqldelight/database.sql index 8c9022c..5de5c0c 100644 --- a/app/src/main/resources/create_db.sql +++ b/app/src/main/sqldelight/database.sql @@ -4,10 +4,14 @@ CREATE TABLE account ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, password TEXT NOT NULL, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created TEXT DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + state INTEGER DEFAULT (1) NOT NULL, unique(email) ); +CREATE INDEX account_state_IDX ON account (state); + + -- document definition CREATE TABLE document ( @@ -18,12 +22,16 @@ CREATE TABLE document ( description TEXT NOT NULL, tags TEXT NOT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - reference_date TEXT, + reference_date TEXT, state INTEGER DEFAULT (1) NOT NULL, CONSTRAINT document_account_FK FOREIGN KEY (account_id) REFERENCES account(id) ON DELETE CASCADE, unique(ext_id) ); +CREATE INDEX document_state_IDX ON document (state); + + -- file definition + CREATE TABLE file ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, @@ -34,8 +42,10 @@ CREATE TABLE file ( content BLOB NOT NULL, content_type TEXT, content_extracted TEXT, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, state INTEGER DEFAULT (1) NOT NULL, CONSTRAINT file_account_FK FOREIGN KEY (account_id) REFERENCES account(id) ON DELETE CASCADE, CONSTRAINT file_document_FK FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE, unique(ext_id) ); + +CREATE INDEX file_state_IDX ON file (state); \ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/Document.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/Document.tpl.html index 1a09e32..db6ef1c 100644 --- a/app/src/main/tpl/net.h34t.filemure.tpl/Document.tpl.html +++ b/app/src/main/tpl/net.h34t.filemure.tpl/Document.tpl.html @@ -8,8 +8,6 @@ {*$description}

- +{template $files} + +

edit

\ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/NewDocumentForm.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/DocumentCreateForm.tpl.html similarity index 78% rename from app/src/main/tpl/net.h34t.filemure.tpl/NewDocumentForm.tpl.html rename to app/src/main/tpl/net.h34t.filemure.tpl/DocumentCreateForm.tpl.html index 2727dac..b5735fa 100644 --- a/app/src/main/tpl/net.h34t.filemure.tpl/NewDocumentForm.tpl.html +++ b/app/src/main/tpl/net.h34t.filemure.tpl/DocumentCreateForm.tpl.html @@ -13,12 +13,8 @@

-
    - {for $files} -
  • {*$filename} ({*$contentType}) -
  • - {/for} -
-

+ + {template $files} + \ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/DocumentEditForm.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/DocumentEditForm.tpl.html new file mode 100644 index 0000000..2342f51 --- /dev/null +++ b/app/src/main/tpl/net.h34t.filemure.tpl/DocumentEditForm.tpl.html @@ -0,0 +1,20 @@ +
+

+ +

+ +

+
{for $tags}{*$tag}{/for}
+ +

+ +

+ + {template $files} + +
\ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html new file mode 100644 index 0000000..1d5dd7d --- /dev/null +++ b/app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html @@ -0,0 +1,24 @@ + + + + + + + {if $delete} + + {/if} + + + {for $files} + + + + + + + {if $delete} + + {/if} + + {/for} +
FilenameTypeSizeDownloadDelete
{*$filename}{*$contentType}{*$size}downloaddelete
\ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/FilePreview.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/FilePreview.tpl.html new file mode 100644 index 0000000..a015085 --- /dev/null +++ b/app/src/main/tpl/net.h34t.filemure.tpl/FilePreview.tpl.html @@ -0,0 +1,9 @@ +
+ {if $embed} + + {/if} + {if $image} + + {/if} + download +
\ No newline at end of file diff --git a/app/src/main/tpl/net.h34t.filemure.tpl/Limbo.tpl.html b/app/src/main/tpl/net.h34t.filemure.tpl/Limbo.tpl.html index 3acd956..236777d 100644 --- a/app/src/main/tpl/net.h34t.filemure.tpl/Limbo.tpl.html +++ b/app/src/main/tpl/net.h34t.filemure.tpl/Limbo.tpl.html @@ -12,7 +12,7 @@ {for $file} - + {*$file} {*$type} {*$size} diff --git a/app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt b/app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt new file mode 100644 index 0000000..6585fec --- /dev/null +++ b/app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt @@ -0,0 +1,36 @@ +package net.h34t.filemure + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.test.Test +import kotlin.test.assertEquals + +class DateGuesserTest { + + private val dtf = DateTimeFormatter.ISO_DATE_TIME + + private fun toDateTime(value: String?): LocalDateTime? = value?.let { LocalDateTime.parse(value, dtf) } + + @Test + fun test_date() { + val values = listOf( + "2023-10-05T00:00" to "20231005_Form.pdf", + "2023-10-05T10:15" to "File-20231005-101500_Form.pdf", + "2023-10-05T00:00" to "File-2023-10-05_Form.pdf", + "2023-10-05T00:00" to "File-2023_10_05_Form.pdf", + "2023-10-05T00:00" to "File-2023_10_05.pdf", + "2023-10-05T11:22:33" to "File.20231005-112233.pdf", + "2022-11-11T00:00:00" to "File.20221111000000.pdf", + "8888-11-11T00:00:00" to "File.88881111000000.pdf", + null to "Document.pdf", + ) + + values.forEach { value -> + assertEquals( + toDateTime(value.first), + DateGuesser.guess(value.second), + "Guessing the date failed for ${value.first} -> ${value.second}" + ) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/net/h34t/filemure/core/entity/Types.kt b/core/src/main/kotlin/net/h34t/filemure/core/entity/Types.kt index fae081d..22c36b5 100644 --- a/core/src/main/kotlin/net/h34t/filemure/core/entity/Types.kt +++ b/core/src/main/kotlin/net/h34t/filemure/core/entity/Types.kt @@ -10,6 +10,7 @@ data class Document( val tags: List, val created: LocalDateTime, val referenceDate: LocalDateTime, + val state: State, val files: List, ) @@ -32,16 +33,23 @@ value class Tag(val value: String) { data class FileRef( val id: Long, val extId: String, + val accountId: Long, + val documentId: Long?, val filename: String, val contentType: String?, val contentExtracted: String?, val fileSize: Long, val created: LocalDateTime, + val state: State ) data class FileContent( val id: Long, val extId: String, + val filename: String, + val contentType: String?, + val contentExtracted: String?, + val fileSize: Long, val content: ByteArray, ) { override fun equals(other: Any?): Boolean { @@ -65,3 +73,17 @@ data class FileContent( } } +enum class State(val code: Int) { + ACTIVE(1), + ARCHIVED(2), + DELETED(3); + + companion object { + fun fromCode(code: Int) = when (code) { + 1 -> ACTIVE + 2 -> ARCHIVED + 3 -> DELETED + else -> throw IllegalArgumentException("$code doesn't exist.") + } + } +} diff --git a/public/filemure.css b/public/filemure.css index a58731f..afb9991 100644 --- a/public/filemure.css +++ b/public/filemure.css @@ -17,3 +17,18 @@ display: block; } +#preview_frame { + position: fixed; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; +} + +#preview_container { + position: absolute; + left: 32px; + right: 32px; + top: 32px; + bottom: 64px; +} \ No newline at end of file