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`.
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")
}
}
}

View file

@ -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? {

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) {
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(

View file

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

View file

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

View file

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

View file

@ -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 = FileList(
modifiers = modifiers,
delete = true,
files = {
document.files.map { file ->
FilesBlock(
extId = file.extId,
filename = file.filename,
contentType = file.contentType ?: "?"
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 = FileList(
modifiers = modifiers,
delete = true,
files = {
selectedFiles
.map {
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")
}

View file

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

View file

@ -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,32 +31,58 @@ 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 ->
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)
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"))
)
}
@ -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,10 +139,28 @@ 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 ->
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>()
@ -126,15 +168,16 @@ class SqliteRepository(url: String) {
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)
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(7), dtf),
referenceDate = LocalDateTime.parse(res.getString(8), dtf),
created = LocalDateTime.parse(res.getString("created"), dtf),
referenceDate = LocalDateTime.parse(res.getString("reference_date"), dtf),
state = State.fromCode(res.getInt("state")),
files = emptyList()
)
)
@ -145,101 +188,75 @@ class SqliteRepository(url: String) {
}
}
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)
val fid: Long = res.getLong("f_id")
val wasNull = res.wasNull()
if (!res.wasNull()) {
if (!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)
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)
)
}
}
}

View file

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

View file

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

View file

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

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>
{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>

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

View file

@ -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;
}