* 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.
It supports:
* uploading of files
* tagging
* search
* 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
This project uses [Gradle](https://gradle.org/).

View file

@ -9,6 +9,9 @@ plugins {
}
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.javalin)
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
import io.javalin.Javalin
import net.h34t.filemure.controller.LimboController
import net.h34t.filemure.controller.LoginController
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 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) {
server.get("/") { ctx ->
if (ctx.sessionAttribute<Session>("user") != null) {
if (ctx.getSession() != null) {
overviewController.overview(ctx)
} else {
ctx.redirectPRG("/login")
@ -23,5 +28,8 @@ class FilemureApp {
server.get("/login", loginPageController::formLogin)
server.post("/login", loginPageController::doLogin)
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
import io.javalin.Javalin
import io.javalin.http.staticfiles.Location
import net.h34t.filemure.FilemureApp
import net.h34t.filemure.repository.SqliteRepository
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 {
app.register(it)
}

View file

@ -1,3 +1,3 @@
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
import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Limbo
import org.apache.commons.text.StringEscapeUtils
import java.net.URLEncoder
class TemplateModifiers : Frame.Modifiers {
class TemplateModifiers : Frame.Modifiers, Limbo.Modifiers {
fun hashPrefix(arg: String): String {
return URLEncoder.encode(arg, Charsets.UTF_8)

View file

@ -28,4 +28,14 @@ fun Context.tempolin(tpl: Template, contentType: String = ContentType.HTML) {
* @param location The location to redirect to.
*
*/
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
import io.javalin.http.Context
import net.h34t.filemure.Session
import net.h34t.filemure.TemplateModifiers
import net.h34t.filemure.redirectPRG
import net.h34t.filemure.tempolin
import net.h34t.filemure.*
import net.h34t.filemure.repository.SqliteRepository
import net.h34t.filemure.tpl.Frame
import net.h34t.filemure.tpl.Login
class LoginController(val modifiers: TemplateModifiers) {
class LoginController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
fun formLogin(ctx: Context) {
ctx.tempolin(
Frame(
modifiers = modifiers,
title = "Hello to Filemure",
isTarget = false,
content = Login(
)
@ -27,16 +26,16 @@ class LoginController(val modifiers: TemplateModifiers) {
val password = ctx.formParam("password")
if (username == "stefan@schallerl.com" && password == "foobar") {
ctx.sessionAttribute("user", Session(email = username))
ctx.setSession(Session(id = 1, email = username))
ctx.redirectPRG("/")
} else {
ctx.sessionAttribute("user", null)
ctx.setSession(null)
ctx.redirectPRG("/")
}
}
fun doLogout(ctx: Context) {
ctx.sessionAttribute("user", null)
ctx.setSession(null)
ctx.redirectPRG("/login")
}
}

View file

@ -2,18 +2,29 @@ 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.Overview
class OverviewController(val modifiers: TemplateModifiers) {
class OverviewController(val modifiers: TemplateModifiers, val repository: SqliteRepository) {
fun overview(ctx: Context) {
val session = ctx.getSession()
requireNotNull(session)
val limboFileCount = repository.getLimboFileCount(accountId = session.id)
ctx.tempolin(
Frame(
modifiers = modifiers,
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)
);
-- document definition
CREATE TABLE document (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
ext_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
tags 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
@ -29,12 +44,13 @@ CREATE UNIQUE INDEX document_ext_id_idx ON document (ext_id);
CREATE TABLE file (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
ext_id TEXT NOT NULL,
name TEXT NOT NULL,
created INTEGER NOT NULL,
contents BLOB NOT NULL,
CONSTRAINT file_document_FK FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE
filename TEXT NOT NULL,
content BLOB NOT NULL,
content_type TEXT,
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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{*$title}</title>
<link rel="stylesheet" type="text/css" href="./filemure.css">
<script src="/filemure.js"></script>
</head>
<body>
{if $isTarget}
<div class="dropzone"></div>
{/if}
{template $content}
</body>
</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>
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));
}
}
});
});