* Adds uploading of files to the limbo.

* Display limbo contents.
This commit is contained in:
Stefan Schallerl 2025-02-02 12:30:04 +01:00
parent 7696868f9b
commit 49abfa3663
19 changed files with 362 additions and 29 deletions

View file

@ -3,11 +3,25 @@
Filemure is a simple document management application. Filemure is a simple document management application.
It supports: It supports:
* uploading of files * uploading of files
* tagging * tagging
* search * search
* download / export * download / export
## TODO
* create document from files in limbo
* display documents list
* display document details
* display files
* in limbo
* in document
* add files to documents
* edit document data
* parse document contents (for pdf, txt, ...)
* guess target date from filenames
## Build ## Build
This project uses [Gradle](https://gradle.org/). This project uses [Gradle](https://gradle.org/).

View file

@ -9,6 +9,9 @@ plugins {
} }
dependencies { dependencies {
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.+")
implementation(libs.slf4jsimple) implementation(libs.slf4jsimple)
implementation(libs.javalin) implementation(libs.javalin)
implementation(libs.commonsText) implementation(libs.commonsText)

View file

@ -0,0 +1,12 @@
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

@ -1,20 +1,25 @@
package net.h34t.filemure package net.h34t.filemure
import io.javalin.Javalin import io.javalin.Javalin
import net.h34t.filemure.controller.LimboController
import net.h34t.filemure.controller.LoginController import net.h34t.filemure.controller.LoginController
import net.h34t.filemure.controller.OverviewController import net.h34t.filemure.controller.OverviewController
import net.h34t.filemure.controller.UploadController
import net.h34t.filemure.repository.SqliteRepository
class FilemureApp { class FilemureApp(repository: SqliteRepository) {
private val modifiers = TemplateModifiers() private val modifiers = TemplateModifiers()
private val loginPageController = LoginController(modifiers) private val loginPageController = LoginController(modifiers, repository)
private val overviewController = OverviewController(modifiers) private val overviewController = OverviewController(modifiers, repository)
private val limboController = LimboController(modifiers, repository)
private val uploadController = UploadController(modifiers, repository)
fun register(server: Javalin) { fun register(server: Javalin) {
server.get("/") { ctx -> server.get("/") { ctx ->
if (ctx.sessionAttribute<Session>("user") != null) { if (ctx.getSession() != null) {
overviewController.overview(ctx) overviewController.overview(ctx)
} else { } else {
ctx.redirectPRG("/login") ctx.redirectPRG("/login")
@ -23,5 +28,8 @@ class FilemureApp {
server.get("/login", loginPageController::formLogin) server.get("/login", loginPageController::formLogin)
server.post("/login", loginPageController::doLogin) server.post("/login", loginPageController::doLogin)
server.post("/logout", loginPageController::doLogout) server.post("/logout", loginPageController::doLogout)
server.post("/upload", uploadController::upload)
server.get("/limbo", limboController::formLimbo)
} }
} }

View file

@ -1,13 +1,27 @@
package net.h34t.app.net.h34t.filemure package net.h34t.app.net.h34t.filemure
import io.javalin.Javalin import io.javalin.Javalin
import io.javalin.http.staticfiles.Location
import net.h34t.filemure.FilemureApp import net.h34t.filemure.FilemureApp
import net.h34t.filemure.repository.SqliteRepository
fun main() { fun main() {
val app = FilemureApp() val app = FilemureApp(SqliteRepository("jdbc:sqlite:filemure.db"))
Javalin.create(/*config*/) Javalin
.create { config ->
config.staticFiles.add { staticFiles ->
staticFiles.hostedPath = "/"
staticFiles.directory = "./public"
staticFiles.location = Location.EXTERNAL
}
config.requestLogger.http { ctx, ms ->
// log things here
}
}
.also { .also {
app.register(it) app.register(it)
} }

View file

@ -1,3 +1,3 @@
package net.h34t.filemure package net.h34t.filemure
data class Session(val email: String) data class Session(val id: Long, val email: String)

View file

@ -1,10 +1,11 @@
package net.h34t.filemure package net.h34t.filemure
import net.h34t.filemure.tpl.Frame import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Limbo
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import java.net.URLEncoder import java.net.URLEncoder
class TemplateModifiers : Frame.Modifiers { class TemplateModifiers : Frame.Modifiers, Limbo.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

@ -29,3 +29,13 @@ fun Context.tempolin(tpl: Template, contentType: String = ContentType.HTML) {
* *
*/ */
fun Context.redirectPRG(location: String) = this.redirect(location, HttpStatus.SEE_OTHER) fun Context.redirectPRG(location: String) = this.redirect(location, HttpStatus.SEE_OTHER)
fun Context.getSession() = this.sessionAttribute<Session>("session")
fun Context.setSession(session: Session?) = this.sessionAttribute("session", session)
private val chars = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun generateExtId(): String {
return (0..8).map { chars.random() }.joinToString("")
}

View file

@ -0,0 +1,47 @@
package net.h34t.filemure.controller
import io.javalin.http.Context
import net.h34t.filemure.TemplateModifiers
import net.h34t.filemure.getSession
import net.h34t.filemure.repository.SqliteRepository
import net.h34t.filemure.tempolin
import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Limbo
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
class LimboController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
private val dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
fun formLimbo(ctx: Context) {
val session = ctx.getSession()
requireNotNull(session)
val files = repository.getFilesInLimbo(accountId = session.id)
ctx.tempolin(
Frame(
modifiers = modifiers,
title = "Filemure Limbo",
isTarget = true,
content = Limbo(
modifiers = modifiers,
limboFileCount = files.size.toString(),
file = {
files.map { f ->
FileBlock(
id = f.id.toString(),
file = f.filename,
type = f.contentType,
size = f.size.toString(),
uploaded = f.created.format(dtf)
)
}.asSequence()
}
)
)
)
}
}

View file

@ -1,20 +1,19 @@
package net.h34t.filemure.controller package net.h34t.filemure.controller
import io.javalin.http.Context import io.javalin.http.Context
import net.h34t.filemure.Session import net.h34t.filemure.*
import net.h34t.filemure.TemplateModifiers import net.h34t.filemure.repository.SqliteRepository
import net.h34t.filemure.redirectPRG
import net.h34t.filemure.tempolin
import net.h34t.filemure.tpl.Frame import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Login import net.h34t.filemure.tpl.Login
class LoginController(val modifiers: TemplateModifiers) { class LoginController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
fun formLogin(ctx: Context) { fun formLogin(ctx: Context) {
ctx.tempolin( ctx.tempolin(
Frame( Frame(
modifiers = modifiers, modifiers = modifiers,
title = "Hello to Filemure", title = "Hello to Filemure",
isTarget = false,
content = Login( content = Login(
) )
@ -27,16 +26,16 @@ class LoginController(val modifiers: TemplateModifiers) {
val password = ctx.formParam("password") val password = ctx.formParam("password")
if (username == "stefan@schallerl.com" && password == "foobar") { if (username == "stefan@schallerl.com" && password == "foobar") {
ctx.sessionAttribute("user", Session(email = username)) ctx.setSession(Session(id = 1, email = username))
ctx.redirectPRG("/") ctx.redirectPRG("/")
} else { } else {
ctx.sessionAttribute("user", null) ctx.setSession(null)
ctx.redirectPRG("/") ctx.redirectPRG("/")
} }
} }
fun doLogout(ctx: Context) { fun doLogout(ctx: Context) {
ctx.sessionAttribute("user", null) ctx.setSession(null)
ctx.redirectPRG("/login") ctx.redirectPRG("/login")
} }
} }

View file

@ -2,18 +2,29 @@ package net.h34t.filemure.controller
import io.javalin.http.Context import io.javalin.http.Context
import net.h34t.filemure.TemplateModifiers import net.h34t.filemure.TemplateModifiers
import net.h34t.filemure.getSession
import net.h34t.filemure.repository.SqliteRepository
import net.h34t.filemure.tempolin import net.h34t.filemure.tempolin
import net.h34t.filemure.tpl.Frame import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Overview import net.h34t.filemure.tpl.Overview
class OverviewController(val modifiers: TemplateModifiers) { class OverviewController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
fun overview(ctx: Context) { fun overview(ctx: Context) {
val session = ctx.getSession()
requireNotNull(session)
val limboFileCount = repository.getLimboFileCount(accountId = session.id)
ctx.tempolin( ctx.tempolin(
Frame( Frame(
modifiers = modifiers, modifiers = modifiers,
title = "Filemure Overview", title = "Filemure Overview",
content = Overview() isTarget = true,
content = Overview(
limboFileCount = limboFileCount.toString()
)
) )
) )
} }

View file

@ -0,0 +1,37 @@
package net.h34t.filemure.controller
import io.javalin.http.Context
import net.h34t.filemure.TemplateModifiers
import net.h34t.filemure.getSession
import net.h34t.filemure.repository.SqliteRepository
class UploadController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
fun upload(ctx: Context) {
println("upload")
val session = ctx.getSession()
requireNotNull(session)
val accountid = session.id
val files = ctx.uploadedFiles()
files.forEach {
println("filename: " + it.filename())
println("ext: " + it.extension())
println("contentType: " + it.contentType()) // mime
println("size: " + it.size())
it.contentAndClose { content ->
repository.addFileToLimbo(accountid, it.filename(), it.contentType(), it.size(), content)
}
}
ctx.status(200)
ctx.json(Result(files = files.size))
}
data class Result(
val files: Int
)
}

View file

@ -0,0 +1,64 @@
package net.h34t.filemure.repository
import net.h34t.filemure.LimboFile
import net.h34t.filemure.generateExtId
import java.io.InputStream
import java.sql.Connection
import java.sql.DriverManager
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class SqliteRepository(url: String) {
private val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val connection: Connection = DriverManager.getConnection(url)
fun addFileToLimbo(accountId: Long, filename: String, contentType: String?, size: Long, content: InputStream) {
connection.prepareStatement("INSERT INTO limbo (account_id, ext_id, filename, content_type, file_size, created, content) VALUES (?,?,?,?,?,datetime(),?)")
.use { stmt ->
stmt.setLong(1, accountId)
stmt.setString(2, generateExtId())
stmt.setString(3, filename)
stmt.setString(4, contentType)
stmt.setLong(5, size)
stmt.setBytes(6, content.readAllBytes())
val res = stmt.executeUpdate()
require(res == 1)
}
}
fun getLimboFileCount(accountId: Long): Long {
connection.prepareStatement("SELECT count(*) as count FROM limbo WHERE account_id=?").use { stmt ->
stmt.setLong(1, accountId)
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 limbo WHERE account_id=? ORDER BY created DESC")
.use { stmt ->
stmt.setLong(1, accountId)
val res = stmt.executeQuery()
val list = mutableListOf<LimboFile>()
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
}
}
}

View file

@ -8,20 +8,35 @@ CREATE TABLE account (
unique(email) unique(email)
); );
-- document definition -- document definition
CREATE TABLE document ( CREATE TABLE document (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
ext_id TEXT NOT NULL, ext_id TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
tags TEXT NOT NULL, tags TEXT NOT NULL,
created TEXT NOT NULL, created TEXT NOT NULL,
reference_date TEXT reference_date TEXT,
CONSTRAINT document_account_FK FOREIGN KEY (account_id) REFERENCES account(id) ON DELETE CASCADE,
unique(ext_id)
); );
CREATE UNIQUE INDEX document_ext_id_idx ON document (ext_id); -- limbo definition
CREATE TABLE limbo (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
ext_id TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT,
file_size INTEGER NOT NULL,
content BLOB NOT NULL,
created TEXT NOT NULL,
unique(ext_id),
CONSTRAINT limbo_account_FK FOREIGN KEY (account_id) REFERENCES account(id) ON DELETE CASCADE,
);
-- file definition -- file definition
@ -29,12 +44,13 @@ CREATE UNIQUE INDEX document_ext_id_idx ON document (ext_id);
CREATE TABLE file ( CREATE TABLE file (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL, document_id INTEGER NOT NULL,
ext_id TEXT NOT NULL, ext_id TEXT NOT NULL,
name TEXT NOT NULL, filename TEXT NOT NULL,
created INTEGER NOT NULL, content BLOB NOT NULL,
contents BLOB NOT NULL, content_type TEXT,
CONSTRAINT file_document_FK FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE content_extracted TEXT,
created TEXT NOT NULL,
CONSTRAINT file_document_FK FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE,
unique(ext_id)
); );
CREATE UNIQUE INDEX file_ext_id_idx ON file (ext_id);

View file

@ -2,9 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{*$title}</title> <title>{*$title}</title>
<link rel="stylesheet" type="text/css" href="./filemure.css">
<script src="/filemure.js"></script>
</head> </head>
<body> <body>
{if $isTarget}
<div class="dropzone"></div>
{/if}
{template $content} {template $content}
</body> </body>
</html> </html>

View file

@ -0,0 +1,24 @@
<h1>Filemure Limbo</h1>
<p>{$limboFileCount} Files</p>
<form action="/document" method="post">
<table>
<tr>
<th>-</th>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th>Uploaded</th>
</tr>
{for $file}
<tr>
<td><label><input type="checkbox" name="limbofile" value="{*$id}"></label></td>
<td>{*$file}</td>
<td>{*$type}</td>
<td>{*$size}</td>
<td>{*$uploaded}</td>
</tr>
{/for}
</table>
<input type="submit" value="new document">
</form>

View file

@ -1,3 +1,4 @@
<h1>Hello to Filemure</h1> <h1>Hello to Filemure</h1>
Your files: Files in <a href="/limbo">limbo: {$limboFileCount}</a>.

19
public/filemure.css Normal file
View file

@ -0,0 +1,19 @@
.dropzone {
position: fixed;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
/* visibility: visible; */
display: none;
border: 0px dotted red;
margin: 4px;
}
.dropzone.dragactive {
background-color: #33FF0000;
border: 3px dotted blue;
/* visibility: visible; */
display: block;
}

47
public/filemure.js Normal file
View file

@ -0,0 +1,47 @@
document.addEventListener("DOMContentLoaded", function() {
console.log("filemure ready")
const dropzone = document.querySelector('html');
// window.addEventListener
// Prevent default behavior for drag-over and drop
dropzone.addEventListener('dragover', (e) => {
console.log("over")
e.preventDefault();
dropzone.classList.add('dragactive');
});
dropzone.addEventListener('dragleave', (e) => {
console.log("out")
dropzone.classList.remove('dragactive');
});
dropzone.addEventListener('drop', (e) => {
console.log("drop")
e.preventDefault();
dropzone.classList.remove('dragactive');
// Handle dropped files
const files = e.dataTransfer.files;
if (files.length > 0) {
console.log('Files dropped:', files);
// Process the files
for (const file of files) {
console.log('File name:', file.name);
console.log('File size:', file.size, 'bytes');
console.log('File type:', file.type);
const formData = new FormData();
formData.append('file', file);
fetch('/upload', {
method: 'POST',
body: formData,
}).then(response => response.json())
.then(data => console.log('Upload successful:', data))
.catch(error => console.error('Upload failed:', error));
}
}
});
});