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:
parent
afa2ffb481
commit
69a43e6252
20 changed files with 540 additions and 235 deletions
|
@ -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 {
|
||||
|
@ -59,3 +62,11 @@ tempolin {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
sqldelight {
|
||||
databases {
|
||||
create("Database") {
|
||||
packageName.set("net.h34t.filemure.db")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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(
|
||||
|
|
|
@ -18,6 +18,7 @@ fun main() {
|
|||
staticFiles.hostedPath = "/"
|
||||
staticFiles.directory = "./public"
|
||||
staticFiles.location = Location.EXTERNAL
|
||||
// staticFiles.
|
||||
}
|
||||
|
||||
config.requestLogger.http { ctx, ms ->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -49,3 +49,11 @@ value class ExtId(val value: String) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<LimboFile> {
|
||||
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<FileRef> {
|
||||
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<LimboFile>()
|
||||
val list = mutableListOf<FileRef>()
|
||||
|
||||
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<String>,
|
||||
description: String,
|
||||
fileIds: List<Long>
|
||||
fileExtIds: List<String>
|
||||
): 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<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=?""")
|
||||
.use { stmt ->
|
||||
stmt.setLong(1, accountId)
|
||||
val res = stmt.executeQuery()
|
||||
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,
|
||||
| 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()) {
|
||||
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<Tag>,
|
||||
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<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")
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -8,8 +8,6 @@
|
|||
{*$description}
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
{for $files}
|
||||
<li><a href="/file/{*$extId}">{*$filename}</a> ({*$contentType})</li>
|
||||
{/for}
|
||||
</ul>
|
||||
{template $files}
|
||||
|
||||
<p><a href="/document/{*$extId}/edit">edit</a></p>
|
|
@ -13,12 +13,8 @@
|
|||
<textarea name="description" rows="40" cols="80">{*$description}</textarea>
|
||||
</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>
|
||||
|
||||
{template $files}
|
||||
|
||||
</form>
|
|
@ -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>
|
24
app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html
Normal file
24
app/src/main/tpl/net.h34t.filemure.tpl/FileList.tpl.html
Normal 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>
|
|
@ -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>
|
|
@ -12,7 +12,7 @@
|
|||
</tr>
|
||||
{for $file}
|
||||
<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>{*$type}</td>
|
||||
<td>{*$size}</td>
|
||||
|
|
36
app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt
Normal file
36
app/src/test/kotlin/net/h34t/filemure/DateGuesserTest.kt
Normal 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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ data class Document(
|
|||
val tags: List<Tag>,
|
||||
val created: LocalDateTime,
|
||||
val referenceDate: LocalDateTime,
|
||||
val state: State,
|
||||
val files: List<FileRef>,
|
||||
)
|
||||
|
||||
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
Loading…
Reference in a new issue