Intermittent commit

* Adds document editing
* Adds human readable byte size formatting
* Adds file downloading
* Uses extIds instead of ids in some places.
This commit is contained in:
Stefan Schallerl 2025-02-05 17:14:45 +01:00
parent afa2ffb481
commit 69a43e6252
20 changed files with 540 additions and 235 deletions

View file

@ -3,12 +3,14 @@ plugins {
// The shared code is located in `buildSrc/src/main/kotlin/kotlin-jvm.gradle.kts`. // The shared code is located in `buildSrc/src/main/kotlin/kotlin-jvm.gradle.kts`.
id("buildsrc.convention.kotlin-jvm") id("buildsrc.convention.kotlin-jvm")
alias(libs.plugins.tempolin) 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. // Apply the Application plugin to add support for building an executable JVM application.
application application
} }
dependencies { dependencies {
implementation("app.cash.sqldelight:sqlite-driver:2.0.2")
implementation("org.xerial:sqlite-jdbc:3.48.0.0") implementation("org.xerial:sqlite-jdbc:3.48.0.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2") implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.+") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.+")
@ -16,6 +18,7 @@ dependencies {
implementation(libs.javalin) implementation(libs.javalin)
implementation(libs.commonsText) implementation(libs.commonsText)
implementation(project(":core")) implementation(project(":core"))
testImplementation(kotlin("test"))
} }
application { application {
@ -58,4 +61,12 @@ tempolin {
templateInterface = "net.h34t.filemure.Template" templateInterface = "net.h34t.filemure.Template"
} }
} }
sqldelight {
databases {
create("Database") {
packageName.set("net.h34t.filemure.db")
}
}
}

View file

@ -3,6 +3,7 @@ package net.h34t.filemure
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
object DateGuesser { 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}\\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}") 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}\\d{2}") to DateTimeFormatter.ofPattern("yyyyMMddHHmmss"),
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}") to DateTimeFormatter.ofPattern("yyyyMMddHHmm"),
) )
private val datePatterns = listOf( private val datePatterns = listOf(
@ -40,12 +41,24 @@ object DateGuesser {
) )
fun guessDateTime(filename: String) = dateTimePatterns.asSequence().mapNotNull { private fun guessDateTime(filename: String) = dateTimePatterns.asSequence().map {
it.first.find(filename)?.let { mr -> LocalDateTime.parse(mr.groups[0]?.value!!, it.second) } it.first.findAll(filename).mapNotNull { mr ->
try {
LocalDateTime.parse(mr.groups[0]?.value!!, it.second)
} catch (e: DateTimeParseException) {
null
}
}.firstOrNull()
}.firstOrNull() }.firstOrNull()
fun guessDate(filename: String) = datePatterns.asSequence().mapNotNull { private fun guessDate(filename: String) = datePatterns.asSequence().map {
it.first.find(filename)?.let { mr -> LocalDate.parse(mr.groups[0]?.value!!, it.second) } it.first.findAll(filename).mapNotNull { mr ->
try {
LocalDate.parse(mr.groups[0]?.value!!, it.second)
} catch (e: DateTimeParseException) {
null
}
}.firstOrNull()
}.firstOrNull() }.firstOrNull()
fun guess(filename: String): LocalDateTime? { fun guess(filename: String): LocalDateTime? {

View file

@ -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,
)

View file

@ -20,8 +20,9 @@ class FilemureApp(repository: SqliteRepository) {
fun register(server: Javalin) { fun register(server: Javalin) {
server.beforeMatched { ctx -> server.beforeMatched { ctx ->
println(ctx.req().pathInfo)
val userRole = getUserRole(ctx) val userRole = getUserRole(ctx)
if (!ctx.routeRoles().contains(userRole)) { if (ctx.routeRoles().isNotEmpty() && !ctx.routeRoles().contains(userRole)) {
ctx.redirectPRG("/login") ctx.redirectPRG("/login")
throw UnauthorizedResponse() throw UnauthorizedResponse()
} }
@ -38,7 +39,11 @@ class FilemureApp(repository: SqliteRepository) {
server.get("/document/new", documentController::createDocumentForm, Role.USER) server.get("/document/new", documentController::createDocumentForm, Role.USER)
server.post("/document/new", documentController::createDocument, Role.USER) server.post("/document/new", documentController::createDocument, Role.USER)
server.get("/document/{extId}", documentController::documentDetail, 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 -> server.exception(UnauthorizedResponse::class.java) { e, ctx ->
ctx.tempolin( ctx.tempolin(

View file

@ -18,6 +18,7 @@ fun main() {
staticFiles.hostedPath = "/" staticFiles.hostedPath = "/"
staticFiles.directory = "./public" staticFiles.directory = "./public"
staticFiles.location = Location.EXTERNAL staticFiles.location = Location.EXTERNAL
// staticFiles.
} }
config.requestLogger.http { ctx, ms -> config.requestLogger.http { ctx, ms ->

View file

@ -4,8 +4,8 @@ import net.h34t.filemure.tpl.*
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import java.net.URLEncoder import java.net.URLEncoder
class TemplateModifiers : Frame.Modifiers, Limbo.Modifiers, NewDocumentForm.Modifiers, Overview.Modifiers, class TemplateModifiers : Frame.Modifiers, Limbo.Modifiers, DocumentCreateForm.Modifiers, Overview.Modifiers,
Document.Modifiers { Document.Modifiers, FilePreview.Modifiers, DocumentEditForm.Modifiers, FileList.Modifiers {
fun hashPrefix(arg: String): String { fun hashPrefix(arg: String): String {
return URLEncoder.encode(arg, Charsets.UTF_8) return URLEncoder.encode(arg, Charsets.UTF_8)

View file

@ -48,4 +48,12 @@ value class ExtId(val value: String) {
return ExtId((0..8).map { chars.random() }.joinToString("")) return ExtId((0..8).map { chars.random() }.joinToString(""))
} }
} }
} }
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"
}

View file

@ -3,11 +3,11 @@ package net.h34t.filemure.controller
import io.javalin.http.BadRequestResponse import io.javalin.http.BadRequestResponse
import io.javalin.http.Context import io.javalin.http.Context
import io.javalin.http.ForbiddenResponse import io.javalin.http.ForbiddenResponse
import io.javalin.http.Header
import net.h34t.filemure.* import net.h34t.filemure.*
import net.h34t.filemure.core.entity.Tag
import net.h34t.filemure.repository.SqliteRepository import net.h34t.filemure.repository.SqliteRepository
import net.h34t.filemure.tpl.Document import net.h34t.filemure.tpl.*
import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.NewDocumentForm
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -16,7 +16,7 @@ import java.time.format.FormatStyle
class DocumentController(val modifiers: TemplateModifiers, val repository: SqliteRepository) { class DocumentController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
private val dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) 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) { fun documentDetail(ctx: Context) {
val session = ctx.requireSession() val session = ctx.requireSession()
@ -31,19 +31,25 @@ class DocumentController(val modifiers: TemplateModifiers, val repository: Sqlit
isTarget = true, isTarget = true,
content = Document( content = Document(
modifiers = modifiers, modifiers = modifiers,
extId = document.extId,
title = document.title, title = document.title,
referenceDate = dtf.format(document.referenceDate), referenceDate = dtf.format(document.referenceDate),
tags = { document.tags.map { TagsBlock(tag = it.value) }.asSequence() }, tags = { document.tags.map { TagsBlock(tag = it.value) }.asSequence() },
description = document.description, description = document.description,
files = { files = FileList(
document.files.map { file -> modifiers = modifiers,
FilesBlock( delete = true,
extId = file.extId, files = {
filename = file.filename, document.files.map { file ->
contentType = file.contentType ?: "?" FilesBlock(
) extId = file.extId,
}.asSequence() 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) { fun createDocumentForm(ctx: Context) {
val session = ctx.requireSession() 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 limboFiles = repository.getFilesInLimbo(session.id)
val limboFileIds = limboFiles.map { it.id } val limboFileIds = limboFiles.map { it.extId }
if (!fileIds.all { it in limboFileIds }) { if (!fileIds.all { it in limboFileIds }) {
throw ForbiddenResponse("Mismatched file ids.") 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 title = selectedFiles.firstOrNull()?.filename ?: "new document"
val referenceDate = selectedFiles.asSequence().mapNotNull { DateGuesser.guess(it.filename) }.firstOrNull() val referenceDates = selectedFiles.mapNotNull { DateGuesser.guess(it.filename) }
val referenceDate = referenceDates.firstOrNull()
?: LocalDateTime.now() ?: LocalDateTime.now()
val tags = selectedFiles.map { File(it.filename).extension }.distinct().asSequence() val tags = selectedFiles.map { File(it.filename).extension }.distinct().asSequence()
val description = "" val description = ""
ctx.tempolin( ctx.tempolin(
Frame( Frame(
modifiers = modifiers, title = "new document", isTarget = true, content = NewDocumentForm( modifiers = modifiers,
title = "new document",
isTarget = true,
content = DocumentCreateForm(
modifiers = modifiers, modifiers = modifiers,
title = title, title = title,
referenceDate = isoDtf.format(referenceDate), referenceDate = formDtf.format(referenceDate),
tags = { tags.map { TagsBlock(it) } }, tags = { tags.map { TagsBlock(it) } },
description = description, description = description,
files = { files = FileList(
selectedFiles modifiers = modifiers,
.map { delete = true,
files = {
selectedFiles.map { file ->
FilesBlock( FilesBlock(
id = it.id.toString(), extId = file.extId,
filename = it.filename, filename = file.filename,
contentType = it.contentType 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") val title = ctx.formParam("title")
?: throw BadRequestResponse("") ?: 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("") ?: throw BadRequestResponse("")
val tags = ctx.formParam("tags")?.split("\\s+") val tags = ctx.formParam("tags")?.split("\\s+")
?: emptyList() ?: emptyList()
val description = ctx.formParam("description") val description = ctx.formParam("description")
?: throw BadRequestResponse("") ?: 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") ctx.redirectPRG("/document/$extId")
} }

View file

@ -26,10 +26,10 @@ class LimboController(val modifiers: TemplateModifiers, val repository: SqliteRe
modifiers = modifiers, limboFileCount = files.size.toString(), file = { modifiers = modifiers, limboFileCount = files.size.toString(), file = {
files.map { f -> files.map { f ->
FileBlock( FileBlock(
id = f.id.toString(), extId = f.extId,
file = f.filename, file = f.filename,
type = f.contentType, type = f.contentType ?: "",
size = f.size.toString(), size = f.fileSize.toString(),
uploaded = f.created.format(dtf) uploaded = f.created.format(dtf)
) )
}.asSequence() }.asSequence()

View file

@ -1,10 +1,8 @@
package net.h34t.filemure.repository package net.h34t.filemure.repository
import net.h34t.filemure.ExtId import net.h34t.filemure.ExtId
import net.h34t.filemure.LimboFile import net.h34t.filemure.core.entity.*
import net.h34t.filemure.core.entity.Document import net.h34t.filemure.core.entity.Tag.Companion.serialize
import net.h34t.filemure.core.entity.FileRef
import net.h34t.filemure.core.entity.Tag
import java.io.InputStream import java.io.InputStream
import java.sql.Connection import java.sql.Connection
import java.sql.DriverManager import java.sql.DriverManager
@ -33,37 +31,63 @@ class SqliteRepository(url: String) {
} }
} }
fun getLimboFileCount(accountId: Long): Long { 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") connection.prepareStatement("SELECT count(*) AS count FROM file WHERE account_id=? AND document_id IS NULL AND state=?")
.use { stmt -> .use { stmt ->
stmt.setLong(1, accountId) stmt.setLong(1, accountId)
stmt.setInt(2, state.code)
val rs = stmt.executeQuery() val rs = stmt.executeQuery()
rs.next() rs.next()
return rs.getLong(1) return rs.getLong(1)
} }
} }
fun getFilesInLimbo(accountId: Long): List<LimboFile> { fun getFilesInLimbo(accountId: Long, state: State = State.ACTIVE): List<FileRef> {
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") connection.prepareStatement(
.use { stmt -> """
stmt.setLong(1, accountId) |SELECT
val res = stmt.executeQuery() | 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<LimboFile>() val list = mutableListOf<FileRef>()
while (res.next()) { while (res.next()) {
list += LimboFile( list += FileRef(
id = res.getLong(1), id = res.getLong("id"),
accountId = res.getLong(2), extId = res.getString("ext_id"),
filename = res.getString(3), accountId = res.getLong("account_id"),
contentType = res.getString(4), documentId = null,
size = res.getLong(5), filename = res.getString("filename"),
created = LocalDateTime.parse(res.getString(6), dtf) contentType = res.getString("content_type"),
) contentExtracted = res.getString("content_extracted"),
} fileSize = res.getLong("file_size"),
created = LocalDateTime.parse(res.getString("created"), dtf),
return list state = State.fromCode(res.getInt("state"))
)
} }
return list
}
} }
fun addDocument( fun addDocument(
@ -72,7 +96,7 @@ class SqliteRepository(url: String) {
referenceDate: LocalDateTime, referenceDate: LocalDateTime,
tags: List<String>, tags: List<String>,
description: String, description: String,
fileIds: List<Long> fileExtIds: List<String>
): ExtId { ): ExtId {
val savePoint = connection.setSavepoint() val savePoint = connection.setSavepoint()
@ -97,14 +121,14 @@ class SqliteRepository(url: String) {
gks.getLong(1) 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 -> .use { stmt ->
stmt.setLong(1, documentId) stmt.setLong(1, documentId)
stmt.setLong(2, accountId) stmt.setLong(2, accountId)
val affected = stmt.executeUpdate() val affected = stmt.executeUpdate()
require(affected == fileIds.size) require(affected == fileExtIds.size)
} }
connection.commit() connection.commit()
@ -115,131 +139,124 @@ class SqliteRepository(url: String) {
} }
} }
fun getDocuments(accountId: Long): List<Document> { fun getDocuments(accountId: Long, state: State = State.ACTIVE): List<Document> {
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=?""") connection.prepareStatement(
.use { stmt -> """
stmt.setLong(1, accountId) |SELECT
val res = stmt.executeQuery() | 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<Document>() val documentList = mutableListOf<Document>()
while (res.next()) { while (res.next()) {
documentList.add( documentList.add(
Document( Document(
id = res.getLong(1), id = res.getLong("id"),
extId = res.getString(3), extId = res.getString("ext_id"),
title = res.getString(4), title = res.getString("title"),
description = res.getString(5), description = res.getString("description"),
tags = res.getString(6) tags = res.getString("tags")
?.let { if (it.isNotBlank()) it.split(",").map { tag -> Tag(tag) } else emptyList() } ?.let { if (it.isNotBlank()) it.split(",").map { tag -> Tag(tag) } else emptyList() }
?: emptyList(), ?: emptyList(),
created = LocalDateTime.parse(res.getString(7), dtf), created = LocalDateTime.parse(res.getString("created"), dtf),
referenceDate = LocalDateTime.parse(res.getString(8), dtf), referenceDate = LocalDateTime.parse(res.getString("reference_date"), dtf),
files = emptyList() state = State.fromCode(res.getInt("state")),
) files = emptyList()
) )
} )
return documentList.toList()
} }
return documentList.toList()
}
} }
fun getDocumentById(accountId: Long, id: Long): Document { fun updateDocument(
return connection.prepareStatement( accountId: Long,
id: Long,
title: String,
referenceDate: LocalDateTime,
tags: List<Tag>,
description: String,
state: State = State.ACTIVE,
) {
connection.prepareStatement(
""" """
SELECT |UPDATE
d.id, | document
d.account_id, |SET
d.ext_id, | title=?,
d.title, | reference_date=?,
d.description, | tags=?,
d.tags, | description=?,
d.created, | state=?
d.reference_date, |WHERE
f.id, | account_id=? AND
f.ext_id, | id=?
f.filename, """.trimMargin()
f.content_type, ).use { stmt ->
f.content_extracted, stmt.setString(1, title)
f.file_size stmt.setString(2, dtf.format(referenceDate))
f.created stmt.setString(3, tags.serialize())
FROM stmt.setString(4, description)
document d LEFT OUTER JOIN file f ON (d.id = f.document_id) stmt.setInt(5, state.code)
WHERE stmt.setLong(6, accountId)
id=? AND stmt.setLong(7, id)
account_id=?
""".trimIndent()
)
.use { stmt ->
stmt.setLong(1, id)
stmt.setLong(2, accountId)
val res = stmt.executeQuery()
var document: Document? = null stmt.executeUpdate()
val files = mutableListOf<FileRef>() }
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")
}
} }
fun getDocumentByExtId(accountId: Long, extId: String): Document { fun getDocumentByExtId(accountId: Long, extId: String): Document {
return connection.prepareStatement( return connection.prepareStatement(
""" """
SELECT |SELECT
d.id, | d.id as d_id,
d.account_id, | d.account_id d_account_id,
d.ext_id, | d.ext_id d_ext_id,
d.title, | d.title d_title,
d.description, | d.description d_description,
d.tags, | d.tags d_tags,
d.created, | d.created d_created,
d.reference_date, | d.reference_date d_reference_date,
f.id, | d.state as d_state,
f.ext_id, | f.id f_id,
f.filename, | f.ext_id f_ext_id,
f.content_type, | f.account_id f_account_id,
f.content_extracted, | f.document_id f_document_id,
f.file_size, | f.filename f_filename,
f.created | f.content_type f_content_type,
FROM | f.content_extracted f_content_extracted,
document d LEFT OUTER JOIN file f ON (d.id = f.document_id) | f.file_size f_file_size,
WHERE | f.created f_created,
d.ext_id=? AND | f.state f_state
d.account_id=? |FROM
""".trimIndent() | document d LEFT OUTER JOIN file f ON (d.id = f.document_id)
|WHERE
| d.ext_id=? AND
| d.account_id=?
""".trimMargin()
) )
.use { stmt -> .use { stmt ->
stmt.setString(1, extId) stmt.setString(1, extId)
stmt.setLong(2, accountId) stmt.setLong(2, accountId)
val res = stmt.executeQuery() val res = stmt.executeQuery()
@ -249,36 +266,76 @@ class SqliteRepository(url: String) {
while (res.next()) { while (res.next()) {
if (document == null) { if (document == null) {
document = Document( document = Document(
id = res.getLong(1), id = res.getLong("d_id"),
extId = res.getString(3), extId = res.getString("d_ext_id"),
title = res.getString(4), title = res.getString("d_title"),
description = res.getString(5), description = res.getString("d_description"),
tags = Tag.parse(res.getString(6)), tags = Tag.parse(res.getString("d_tags")),
created = LocalDateTime.parse(res.getString(7), dtf), created = LocalDateTime.parse(res.getString("d_created"), dtf),
referenceDate = LocalDateTime.parse(res.getString(8), dtf), referenceDate = LocalDateTime.parse(res.getString("d_reference_date"), dtf),
state = State.fromCode(res.getInt("d_state")),
files = emptyList() 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) requireNotNull(document)
document.copy(files = files) 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)
)
}
}
} }

View file

@ -4,10 +4,14 @@ CREATE TABLE account (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL, email TEXT NOT NULL,
password 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) unique(email)
); );
CREATE INDEX account_state_IDX ON account (state);
-- document definition -- document definition
CREATE TABLE document ( CREATE TABLE document (
@ -18,12 +22,16 @@ CREATE TABLE document (
description TEXT NOT NULL, description TEXT NOT NULL,
tags TEXT NOT NULL, tags TEXT NOT NULL,
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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, CONSTRAINT document_account_FK FOREIGN KEY (account_id) REFERENCES account(id) ON DELETE CASCADE,
unique(ext_id) unique(ext_id)
); );
CREATE INDEX document_state_IDX ON document (state);
-- file definition -- file definition
CREATE TABLE file ( CREATE TABLE file (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
@ -34,8 +42,10 @@ CREATE TABLE file (
content BLOB NOT NULL, content BLOB NOT NULL,
content_type TEXT, content_type TEXT,
content_extracted 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_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, CONSTRAINT file_document_FK FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE,
unique(ext_id) unique(ext_id)
); );
CREATE INDEX file_state_IDX ON file (state);

View file

@ -8,8 +8,6 @@
{*$description} {*$description}
</p> </p>
<ul> {template $files}
{for $files}
<li><a href="/file/{*$extId}">{*$filename}</a> ({*$contentType})</li> <p><a href="/document/{*$extId}/edit">edit</a></p>
{/for}
</ul>

View file

@ -13,12 +13,8 @@
<textarea name="description" rows="40" cols="80">{*$description}</textarea> <textarea name="description" rows="40" cols="80">{*$description}</textarea>
</label></p> </label></p>
<ul>
{for $files}
<li>{*$filename} ({*$contentType})
<input type="hidden" name="file_id" value="{$id}"></li>
{/for}
</ul>
<p><input type="submit" value="create"></p> <p><input type="submit" value="create"></p>
{template $files}
</form> </form>

View file

@ -0,0 +1,20 @@
<form action="/document/{*$extId}/edit" method="post">
<p><label>Document title:<br>
<input type="text" name="title" value="{*$title}"></label></p>
<p><label>Date:<br>
<input type="datetime-local" name="reference_date" value="{*$referenceDate}"></label></p>
<p><label>Tags:<br>
<input type="text" name="tags"></label></p>
<div id="tags">{for $tags}<span>{*$tag}</span>{/for}</div>
<p><label>Description:<br>
<textarea name="description" rows="40" cols="80">{*$description}</textarea>
</label></p>
<p><input type="submit" value="save"></p>
{template $files}
</form>

View file

@ -0,0 +1,24 @@
<table>
<tr>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th>Download</th>
{if $delete}
<th>Delete</th>
{/if}
</tr>
{for $files}
<tr>
<td>{*$filename}</td>
<td>{*$contentType}</td>
<td>{*$size}</td>
<td><a href="/file/{*$extId}/download">download</a></td>
{if $delete}
<td><a href="/file/{*$extId}/delete">delete</a></td>
{/if}
</tr>
{/for}
</table>

View file

@ -0,0 +1,9 @@
<div class="preview_frame">
{if $embed}
<iframe class="preview_container" src="{$contentUrl}"></iframe>
{/if}
{if $image}
<img src="{$contentUrl}">
{/if}
<a href="/file/download/{*$extId}" class="download">download</a>
</div>

View file

@ -12,7 +12,7 @@
</tr> </tr>
{for $file} {for $file}
<tr> <tr>
<td><label><input type="checkbox" name="file_id" value="{*$id}"></label></td> <td><label><input type="checkbox" name="file_id" value="{*$extId}"></label></td>
<td>{*$file}</td> <td>{*$file}</td>
<td>{*$type}</td> <td>{*$type}</td>
<td>{*$size}</td> <td>{*$size}</td>

View file

@ -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}"
)
}
}
}

View file

@ -10,6 +10,7 @@ data class Document(
val tags: List<Tag>, val tags: List<Tag>,
val created: LocalDateTime, val created: LocalDateTime,
val referenceDate: LocalDateTime, val referenceDate: LocalDateTime,
val state: State,
val files: List<FileRef>, val files: List<FileRef>,
) )
@ -32,16 +33,23 @@ value class Tag(val value: String) {
data class FileRef( data class FileRef(
val id: Long, val id: Long,
val extId: String, val extId: String,
val accountId: Long,
val documentId: Long?,
val filename: String, val filename: String,
val contentType: String?, val contentType: String?,
val contentExtracted: String?, val contentExtracted: String?,
val fileSize: Long, val fileSize: Long,
val created: LocalDateTime, val created: LocalDateTime,
val state: State
) )
data class FileContent( data class FileContent(
val id: Long, val id: Long,
val extId: String, val extId: String,
val filename: String,
val contentType: String?,
val contentExtracted: String?,
val fileSize: Long,
val content: ByteArray, val content: ByteArray,
) { ) {
override fun equals(other: Any?): Boolean { 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.")
}
}
}

View file

@ -17,3 +17,18 @@
display: block; 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;
}