feat(server): add D&D resource upload with metadata extraction and tag suggestions

- Add ResourceAnalyzer: PDF (PDFBox), EPUB, image (EXIF/IPTC) metadata extraction
- Add POST /api/admin/resources/analyze + /confirm endpoints
- Add GET /api/admin/resources/tags with default tag seeding
- Admin UI: D&D zone, review panel with textarea description (4096 chars, MD), tag chips
- Dependencies: PDFBox 3.0.4, Commons Compress 1.27.1, metadata-extractor 2.19.0
This commit is contained in:
Jens Reinemann 2026-05-18 23:13:17 +02:00
parent 25c5f4675f
commit e88e2d04c0
5 changed files with 850 additions and 0 deletions

View file

@ -26,6 +26,9 @@ postgresql = "42.7.4"
hikaricp = "6.2.1" hikaricp = "6.2.1"
jbcrypt = "0.4" jbcrypt = "0.4"
flyway = "9.22.3" flyway = "9.22.3"
pdfbox = "3.0.4"
commonsCompress = "1.27.1"
metadataExtractor = "2.19.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -83,6 +86,9 @@ tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "
tink = { module = "com.google.crypto.tink:tink", version.ref = "tink" } tink = { module = "com.google.crypto.tink:tink", version.ref = "tink" }
postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" } postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" }
hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp" } hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp" }
pdfbox = { group = "org.apache.pdfbox", name = "pdfbox", version.ref = "pdfbox" }
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
metadata-extractor = { group = "com.drewnoakes", name = "metadata-extractor", version.ref = "metadataExtractor" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View file

@ -46,6 +46,9 @@ dependencies {
implementation(libs.hikaricp) implementation(libs.hikaricp)
implementation(libs.flyway.core) implementation(libs.flyway.core)
implementation(libs.tink) implementation(libs.tink)
implementation(libs.pdfbox)
implementation(libs.commons.compress)
implementation(libs.metadata.extractor)
testImplementation(libs.h2.database) testImplementation(libs.h2.database)
testImplementation(libs.ktor.server.test.host) testImplementation(libs.ktor.server.test.host)

View file

@ -3,6 +3,8 @@ package de.bollwerk.server.routes
import de.bollwerk.server.model.ErrorResponse import de.bollwerk.server.model.ErrorResponse
import de.bollwerk.server.repository.ResourceRepository import de.bollwerk.server.repository.ResourceRepository
import de.bollwerk.server.security.UserPrincipal import de.bollwerk.server.security.UserPrincipal
import de.bollwerk.server.service.AnalyzedResource
import de.bollwerk.server.service.ResourceAnalyzer
import de.bollwerk.shared.model.ResourceDto import de.bollwerk.shared.model.ResourceDto
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.content.* import io.ktor.http.content.*
@ -10,12 +12,14 @@ import io.ktor.server.auth.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
private const val MAX_RESOURCE_UPLOAD_SIZE = 25L * 1024 * 1024 // 25 MB private const val MAX_RESOURCE_UPLOAD_SIZE = 25L * 1024 * 1024 // 25 MB
private val RESOURCE_STORAGE_DIR = File(System.getenv("BOLLWERK_RESOURCE_DIR") ?: "/opt/bollwerk/resources") private val RESOURCE_STORAGE_DIR = File(System.getenv("BOLLWERK_RESOURCE_DIR") ?: "/opt/bollwerk/resources")
private val resourceAnalyzer = ResourceAnalyzer()
internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) { internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
// Authenticated: catalog + download // Authenticated: catalog + download
@ -43,6 +47,28 @@ internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
} }
} }
// Tags endpoint: return all known tags with frequency, or defaults if none exist
route("/api/admin/resources/tags") {
get {
val principal = call.principal<UserPrincipal>()
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
if (!principal.isAdmin) {
return@get call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
}
val allResources = resourceRepository.getAll()
val tagFrequency = mutableMapOf<String, Int>()
allResources.forEach { res ->
res.tags.forEach { tag -> tagFrequency[tag] = (tagFrequency[tag] ?: 0) + 1 }
}
// If no tags exist, provide defaults
if (tagFrequency.isEmpty()) {
DEFAULT_TAGS.forEach { tagFrequency[it] = 0 }
}
val sorted = tagFrequency.entries.sortedByDescending { it.value }.map { TagInfo(it.key, it.value) }
call.respond(HttpStatusCode.OK, sorted)
}
}
// Admin-only: CRUD // Admin-only: CRUD
route("/api/admin/resources") { route("/api/admin/resources") {
post { post {
@ -131,4 +157,105 @@ internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
call.respond(HttpStatusCode.NoContent) call.respond(HttpStatusCode.NoContent)
} }
} }
// Analyze endpoint: upload file, extract metadata, return suggestions (without persisting)
route("/api/admin/resources/analyze") {
post {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
if (!principal.isAdmin) {
return@post call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
} }
val multipart = call.receiveMultipart()
var filename = "unknown"
var fileBytes: ByteArray? = null
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
if (part.name == "file") {
filename = part.originalFileName ?: "unknown"
fileBytes = part.streamProvider().readBytes()
}
}
else -> {}
}
part.dispose()
}
val bytes = fileBytes
?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing file"))
if (bytes.size > MAX_RESOURCE_UPLOAD_SIZE) {
return@post call.respond(HttpStatusCode.PayloadTooLarge, ErrorResponse(413, "File too large (max 25 MB)"))
}
val analyzed = resourceAnalyzer.analyze(filename, bytes)
// Store temporary files so they can be confirmed later
val tempDir = File(RESOURCE_STORAGE_DIR, ".tmp").also { it.mkdirs() }
val responseItems = analyzed.map { item ->
val tempId = UUID.randomUUID().toString()
val targetFile = File(tempDir, "${tempId}.${item.fileFormat}")
targetFile.writeBytes(item.fileBytes ?: bytes)
item.copy(fileBytes = null, filename = "${tempId}|${item.filename}")
}
call.respond(HttpStatusCode.OK, responseItems)
}
}
// Confirm a previously analyzed resource
route("/api/admin/resources/confirm") {
post {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
if (!principal.isAdmin) {
return@post call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
}
val request = call.receive<AnalyzedResource>()
val parts = request.filename.split("|", limit = 2)
val tempId = parts[0]
val originalFilename = parts.getOrElse(1) { request.filename }
val tempFile = File(RESOURCE_STORAGE_DIR, ".tmp/${tempId}.${request.fileFormat}")
if (!tempFile.exists()) {
return@post call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Temp file not found please re-upload"))
}
val guid = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val dto = ResourceDto(
guid = guid,
title = request.title,
description = request.description,
tags = request.tags,
fileFormat = request.fileFormat,
mimeType = request.mimeType,
fileSize = tempFile.length(),
releaseDate = request.releaseDate,
createdAt = now,
updatedAt = now,
author = request.author,
language = request.language,
edition = request.edition,
downloadUrl = "/api/resources/$guid/download"
)
RESOURCE_STORAGE_DIR.mkdirs()
tempFile.renameTo(File(RESOURCE_STORAGE_DIR, "${guid}.${dto.fileFormat}"))
resourceRepository.create(dto)
call.respond(HttpStatusCode.Created, dto)
}
}
}
@Serializable
private data class TagInfo(val tag: String, val count: Int)
private val DEFAULT_TAGS = listOf(
"gurps", "rulebook", "novel", "map", "handbuch",
"anleitung", "nachschlagewerk", "karte", "abenteuer", "quellenbuch"
)

View file

@ -0,0 +1,272 @@
package de.bollwerk.server.service
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.iptc.IptcDirectory
import kotlinx.serialization.Serializable
import org.apache.commons.compress.archivers.sevenz.SevenZFile
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel
import org.apache.pdfbox.Loader
import org.apache.pdfbox.pdmodel.PDDocument
import java.io.ByteArrayInputStream
import java.io.File
import java.util.zip.ZipInputStream
@Serializable
data class AnalyzedResource(
val filename: String,
val title: String,
val description: String = "",
val author: String? = null,
val tags: List<String> = emptyList(),
val fileFormat: String,
val mimeType: String,
val fileSize: Long,
val language: String? = null,
val edition: String? = null,
val releaseDate: String? = null,
val fileBytes: ByteArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnalyzedResource) return false
return filename == other.filename && title == other.title
}
override fun hashCode(): Int = filename.hashCode() * 31 + title.hashCode()
}
internal class ResourceAnalyzer {
private val supportedExtensions = setOf("pdf", "epub", "zip", "7z", "png", "jpg", "jpeg", "webp", "gif", "bmp")
fun analyze(filename: String, bytes: ByteArray): List<AnalyzedResource> {
val ext = filename.substringAfterLast('.', "").lowercase()
return when (ext) {
"zip" -> analyzeZip(bytes)
"7z" -> analyze7z(bytes)
else -> listOf(analyzeSingleFile(filename, bytes))
}
}
private fun analyzeZip(bytes: ByteArray): List<AnalyzedResource> {
val results = mutableListOf<AnalyzedResource>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
val name = entry.name.substringAfterLast('/')
val ext = name.substringAfterLast('.', "").lowercase()
if (ext in supportedExtensions && ext != "zip" && ext != "7z") {
val fileBytes = zis.readBytes()
results.add(analyzeSingleFile(name, fileBytes))
}
}
entry = zis.nextEntry
}
}
return results
}
private fun analyze7z(bytes: ByteArray): List<AnalyzedResource> {
val results = mutableListOf<AnalyzedResource>()
val channel = SeekableInMemoryByteChannel(bytes)
SevenZFile.builder().setSeekableByteChannel(channel).get().use { sevenZ ->
var entry = sevenZ.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
val name = entry.name.substringAfterLast('/')
val ext = name.substringAfterLast('.', "").lowercase()
if (ext in supportedExtensions && ext != "zip" && ext != "7z") {
val fileBytes = sevenZ.getInputStream(entry).readBytes()
results.add(analyzeSingleFile(name, fileBytes))
}
}
entry = sevenZ.nextEntry
}
}
return results
}
private fun analyzeSingleFile(filename: String, bytes: ByteArray): AnalyzedResource {
val ext = filename.substringAfterLast('.', "").lowercase()
val mimeType = mimeTypeFor(ext)
val cleanTitle = cleanTitle(filename.substringBeforeLast('.'))
return when (ext) {
"pdf" -> analyzePdf(filename, bytes, cleanTitle, ext, mimeType)
"epub" -> analyzeEpub(filename, bytes, cleanTitle, ext, mimeType)
"png", "jpg", "jpeg", "webp", "gif", "bmp" -> analyzeImage(filename, bytes, cleanTitle, ext, mimeType)
else -> AnalyzedResource(
filename = filename,
title = cleanTitle,
fileFormat = ext,
mimeType = mimeType,
fileSize = bytes.size.toLong(),
fileBytes = bytes
)
}
}
private fun analyzePdf(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
var title = fallbackTitle
var author: String? = null
var description = ""
var language: String? = null
var tags = emptyList<String>()
try {
Loader.loadPDF(bytes).use { doc: PDDocument ->
val info = doc.documentInformation
if (!info.title.isNullOrBlank()) {
title = cleanTitle(info.title)
}
if (!info.author.isNullOrBlank()) {
author = info.author.trim()
}
if (!info.subject.isNullOrBlank()) {
description = info.subject.trim()
}
if (!info.keywords.isNullOrBlank()) {
tags = info.keywords.split(",", ";").map { it.trim() }.filter { it.isNotBlank() }
}
// Try to detect language from metadata (custom property)
val custom = info.metadataKeys
if ("Language" in custom) {
language = info.getCustomMetadataValue("Language")?.trim()
}
}
} catch (_: Exception) {
// PDF parsing failed use defaults
}
return AnalyzedResource(
filename = filename,
title = title,
description = description,
author = author,
tags = tags,
fileFormat = ext,
mimeType = mimeType,
fileSize = bytes.size.toLong(),
language = language,
fileBytes = bytes
)
}
private fun analyzeEpub(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
var title = fallbackTitle
var author: String? = null
var description = ""
var language: String? = null
var tags = emptyList<String>()
try {
// EPUB is a zip look for content.opf
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (entry.name.endsWith(".opf")) {
val opfContent = zis.readBytes().decodeToString()
title = extractXmlTag(opfContent, "dc:title") ?: title
author = extractXmlTag(opfContent, "dc:creator")
description = extractXmlTag(opfContent, "dc:description") ?: ""
language = extractXmlTag(opfContent, "dc:language")
val subject = extractXmlTag(opfContent, "dc:subject")
if (subject != null) {
tags = subject.split(",", ";").map { it.trim() }.filter { it.isNotBlank() }
}
break
}
entry = zis.nextEntry
}
}
} catch (_: Exception) {
// EPUB parsing failed
}
return AnalyzedResource(
filename = filename,
title = cleanTitle(title),
description = description,
author = author,
tags = tags,
fileFormat = ext,
mimeType = mimeType,
fileSize = bytes.size.toLong(),
language = language,
fileBytes = bytes
)
}
private fun analyzeImage(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
var title = fallbackTitle
var author: String? = null
var description = ""
var tags = emptyList<String>()
try {
val metadata = ImageMetadataReader.readMetadata(ByteArrayInputStream(bytes))
// IPTC metadata
val iptc = metadata.getFirstDirectoryOfType(IptcDirectory::class.java)
if (iptc != null) {
iptc.getString(IptcDirectory.TAG_HEADLINE)?.takeIf { it.isNotBlank() }?.let { title = it.trim() }
iptc.getString(IptcDirectory.TAG_BY_LINE)?.takeIf { it.isNotBlank() }?.let { author = it.trim() }
iptc.getString(IptcDirectory.TAG_CAPTION)?.takeIf { it.isNotBlank() }?.let { description = it.trim() }
iptc.getStringArray(IptcDirectory.TAG_KEYWORDS)?.let { kw ->
tags = kw.toList().filter { it.isNotBlank() }
}
}
// EXIF fallback for author
if (author == null) {
val exif = metadata.getFirstDirectoryOfType(ExifIFD0Directory::class.java)
exif?.getString(ExifIFD0Directory.TAG_ARTIST)?.takeIf { it.isNotBlank() }?.let { author = it.trim() }
}
} catch (_: Exception) {
// Image metadata parsing failed
}
return AnalyzedResource(
filename = filename,
title = title,
description = description,
author = author,
tags = tags,
fileFormat = ext,
mimeType = mimeType,
fileSize = bytes.size.toLong(),
fileBytes = bytes
)
}
private fun extractXmlTag(xml: String, tag: String): String? {
val regex = Regex("<$tag[^>]*>([^<]+)</$tag>", RegexOption.IGNORE_CASE)
return regex.find(xml)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
}
internal fun cleanTitle(raw: String): String {
return raw
.replace(Regex("[_\\-]+"), " ") // underscores/hyphens → spaces
.replace(Regex("\\s+"), " ") // collapse whitespace
.replace(Regex("^\\d{4,}\\s*"), "") // remove leading numeric IDs
.replace(Regex("\\(\\d+\\)$"), "") // remove trailing "(123)"
.replace(Regex("\\[.*?]"), "") // remove [bracketed] content
.trim()
.replaceFirstChar { it.uppercaseChar() } // capitalize first letter
}
private fun mimeTypeFor(ext: String): String = when (ext) {
"pdf" -> "application/pdf"
"epub" -> "application/epub+zip"
"zip" -> "application/zip"
"7z" -> "application/x-7z-compressed"
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"webp" -> "image/webp"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
else -> "application/octet-stream"
}
}

View file

@ -124,6 +124,7 @@
<nav class="tab-bar"> <nav class="tab-bar">
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button> <button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button> <button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
<button class="tab-btn" data-tab="tab-resources" onclick="switchTab('tab-resources')">Ressourcen</button>
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button> <button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
</nav> </nav>
@ -229,6 +230,75 @@
</div><!-- /tab-inventories --> </div><!-- /tab-inventories -->
<!-- Tab: Ressourcen -->
<div id="tab-resources" class="tab-content">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Ressourcen-Katalog</h2>
<div style="display:flex; gap:8px">
<button class="secondary" onclick="loadResources()">↻ Aktualisieren</button>
<button class="primary" onclick="toggleResourceForm()">+ Manuell hochladen</button>
<button class="primary" onclick="toggleDndZone()">⇪ Drag &amp; Drop</button>
</div>
</div>
<!-- Drag & Drop Zone -->
<div id="dnd-zone" style="display:none; margin-bottom:16px;">
<div id="dnd-area" style="border:2px dashed #4A3F2F; border-radius:3px; padding:48px 24px; text-align:center; background:#1E1C17; cursor:pointer; transition: border-color .2s, background .2s;">
<p style="font-size:1.1rem; color:#A89070; margin-bottom:8px;">Datei hierher ziehen</p>
<p style="font-size:.85rem; color:#6B5B3E;">PDF, EPUB, Bilder, ZIP, 7z max 25 MB</p>
<p style="font-size:.85rem; color:#6B5B3E; margin-top:4px;">Metadaten werden automatisch extrahiert</p>
</div>
<div id="dnd-progress" style="display:none; text-align:center; padding:20px; color:#A89070;">
Wird analysiert…
</div>
</div>
<!-- Review Panel for analyzed resources -->
<div id="dnd-review" style="display:none; margin-bottom:16px;"></div>
<div id="add-resource-form" style="display:none; background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:20px; margin-bottom:16px;">
<h3 style="margin-bottom:14px; color:#E8D5B0;">Neue Ressource hochladen</h3>
<div id="resource-error" class="error" style="display:none"></div>
<label>Titel *</label>
<input id="res-title" type="text" placeholder="z.B. GURPS Basic Set">
<label>Beschreibung (Markdown, max 4096 Zeichen)</label>
<textarea id="res-description" maxlength="4096" rows="4" placeholder="Freitext-Beschreibung…" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit; margin-bottom:14px;"></textarea>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div><label>Autor</label><input id="res-author" type="text"></div>
<div><label>Sprache</label><input id="res-language" type="text" placeholder="de, en, …"></div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div><label>Edition</label><input id="res-edition" type="text" placeholder="z.B. 4th Edition"></div>
<div><label>Erscheinungsdatum</label><input id="res-releaseDate" type="date"></div>
</div>
<label>Tags (kommagetrennt)</label>
<input id="res-tags" type="text" placeholder="gurps, rulebook, deutsch">
<label>Datei * (max 25 MB)</label>
<input id="res-file" type="file" accept=".epub,.pdf,.zip,.7z" style="padding:6px">
<div style="display:flex; gap:10px; margin-top:10px">
<button class="primary" onclick="uploadResource()">Hochladen</button>
<button class="secondary" onclick="toggleResourceForm()">Abbrechen</button>
</div>
</div>
<table id="resources-table">
<thead>
<tr>
<th>Titel</th>
<th>Format</th>
<th>Größe</th>
<th>Tags</th>
<th>Autor</th>
<th>Hochgeladen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="resources-body"><tr><td colspan="7" style="text-align:center;color:#6B5B3E">Tab auswählen um zu laden…</td></tr></tbody>
</table>
</section>
</div><!-- /tab-resources -->
<!-- Tab: Backups --> <!-- Tab: Backups -->
<div id="tab-backups" class="tab-content"> <div id="tab-backups" class="tab-content">
<section class="card"> <section class="card">
@ -683,6 +753,7 @@
// ── Tab Navigation ─────────────────────────────────────────────────────── // ── Tab Navigation ───────────────────────────────────────────────────────
let backupsLoaded = false; let backupsLoaded = false;
let resourcesLoaded = false;
function switchTab(tabId) { function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId)); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
@ -690,6 +761,9 @@
if (tabId === 'tab-backups' && !backupsLoaded) { if (tabId === 'tab-backups' && !backupsLoaded) {
loadBackups(); loadBackups();
} }
if (tabId === 'tab-resources' && !resourcesLoaded) {
loadResources();
}
} }
// ── Backups ────────────────────────────────────────────────────────────── // ── Backups ──────────────────────────────────────────────────────────────
@ -722,6 +796,374 @@
} }
} }
// ── Ressourcen ─────────────────────────────────────────────────────────
function toggleResourceForm() {
const form = document.getElementById('add-resource-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
function toggleDndZone() {
const zone = document.getElementById('dnd-zone');
zone.style.display = zone.style.display === 'none' ? 'block' : 'none';
}
// Drag & Drop setup
document.addEventListener('DOMContentLoaded', () => {
const dndArea = document.getElementById('dnd-area');
if (!dndArea) return;
['dragenter', 'dragover'].forEach(ev => {
dndArea.addEventListener(ev, e => {
e.preventDefault();
dndArea.style.borderColor = '#C1440E';
dndArea.style.background = '#2E2B22';
});
});
['dragleave', 'drop'].forEach(ev => {
dndArea.addEventListener(ev, e => {
e.preventDefault();
dndArea.style.borderColor = '#4A3F2F';
dndArea.style.background = '#1E1C17';
});
});
dndArea.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length > 0) analyzeDroppedFile(files[0]);
});
dndArea.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.epub,.png,.jpg,.jpeg,.webp,.gif,.zip,.7z';
input.onchange = () => { if (input.files.length) analyzeDroppedFile(input.files[0]); };
input.click();
});
});
async function analyzeDroppedFile(file) {
const progress = document.getElementById('dnd-progress');
const dndArea = document.getElementById('dnd-area');
const review = document.getElementById('dnd-review');
dndArea.style.display = 'none';
progress.style.display = 'block';
review.style.display = 'none';
review.innerHTML = '';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/admin/resources/analyze', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken },
body: formData
});
progress.style.display = 'none';
if (!res.ok) {
const body = await res.json().catch(() => ({}));
alert('Analyse fehlgeschlagen: ' + (body.message || res.status));
dndArea.style.display = 'block';
return;
}
const items = await res.json();
if (!items.length) {
alert('Keine unterstützten Dateien gefunden.');
dndArea.style.display = 'block';
return;
}
renderReviewPanel(items);
} catch (e) {
progress.style.display = 'none';
dndArea.style.display = 'block';
alert('Verbindungsfehler: ' + e.message);
}
}
function renderReviewPanel(items) {
const review = document.getElementById('dnd-review');
review.style.display = 'block';
review.innerHTML = '<h3 style="color:#E8D5B0; margin-bottom:12px;">Erkannte Ressourcen (' + items.length + ')</h3>';
items.forEach((item, idx) => {
const card = document.createElement('div');
card.style.cssText = 'background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:16px; margin-bottom:12px;';
card.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<strong style="color:#E8D5B0;">${item.fileFormat.toUpperCase()} ${formatBytes(item.fileSize)}</strong>
<button class="primary" onclick="confirmAnalyzedResource(${idx})">✓ Übernehmen</button>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div><label>Titel *</label><input class="rv-title" value="${escHtml(item.title)}" style="width:100%"></div>
<div><label>Autor</label><input class="rv-author" value="${escHtml(item.author || '')}" style="width:100%"></div>
</div>
<div style="margin-top:8px;">
<label>Beschreibung (Markdown)</label>
<textarea class="rv-description" maxlength="4096" rows="4" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit;">${escHtml(item.description)}</textarea>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-top:8px;">
<div><label>Sprache</label><input class="rv-language" value="${escHtml(item.language || '')}" style="width:100%" placeholder="de, en, …"></div>
<div><label>Edition</label><input class="rv-edition" value="${escHtml(item.edition || '')}" style="width:100%"></div>
<div><label>Erscheinungsdatum</label><input class="rv-releaseDate" type="date" value="${escHtml(item.releaseDate || '')}" style="width:100%"></div>
</div>
<div style="margin-top:8px;">
<label>Tags</label>
<input class="rv-tags" value="${escHtml((item.tags||[]).join(', '))}" style="width:100%; margin-bottom:6px;" placeholder="Kommagetrennt eingeben oder unten klicken">
<div class="rv-tag-suggestions" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
`;
card.dataset.item = JSON.stringify(item);
review.appendChild(card);
});
// Load tag suggestions
loadTagSuggestions();
const btnAll = document.createElement('button');
btnAll.className = 'primary';
btnAll.textContent = 'Alle übernehmen (' + items.length + ')';
btnAll.style.marginTop = '8px';
btnAll.onclick = confirmAllAnalyzed;
review.appendChild(btnAll);
const btnCancel = document.createElement('button');
btnCancel.className = 'secondary';
btnCancel.textContent = 'Abbrechen';
btnCancel.style.cssText = 'margin-top:8px; margin-left:8px;';
btnCancel.onclick = () => {
review.style.display = 'none';
review.innerHTML = '';
document.getElementById('dnd-area').style.display = 'block';
};
review.appendChild(btnCancel);
}
async function confirmAnalyzedResource(idx) {
const review = document.getElementById('dnd-review');
const cards = review.querySelectorAll('[data-item]');
const card = cards[idx];
if (!card) return;
const item = JSON.parse(card.dataset.item);
const payload = {
filename: item.filename,
title: card.querySelector('.rv-title').value.trim(),
description: card.querySelector('.rv-description').value.trim(),
author: card.querySelector('.rv-author').value.trim() || null,
tags: card.querySelector('.rv-tags').value.split(',').map(t => t.trim()).filter(Boolean),
fileFormat: item.fileFormat,
mimeType: item.mimeType,
fileSize: item.fileSize,
language: card.querySelector('.rv-language').value.trim() || null,
edition: card.querySelector('.rv-edition').value.trim() || null,
releaseDate: card.querySelector('.rv-releaseDate').value || null
};
try {
const res = await fetch('/api/admin/resources/confirm', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
card.style.opacity = '0.4';
card.querySelector('button').disabled = true;
card.querySelector('button').textContent = '✓ Gespeichert';
} else {
const body = await res.json().catch(() => ({}));
alert('Fehler: ' + (body.message || res.status));
}
} catch (e) {
alert('Verbindungsfehler: ' + e.message);
}
}
async function confirmAllAnalyzed() {
const review = document.getElementById('dnd-review');
const cards = review.querySelectorAll('[data-item]');
for (let i = 0; i < cards.length; i++) {
if (cards[i].style.opacity !== '0.4') {
await confirmAnalyzedResource(i);
}
}
resourcesLoaded = false;
loadResources();
}
function escHtml(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
let cachedTags = null;
async function loadTagSuggestions() {
if (!cachedTags) {
try {
const res = await fetch('/api/admin/resources/tags', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (res.ok) cachedTags = await res.json();
else cachedTags = [];
} catch (e) { cachedTags = []; }
}
document.querySelectorAll('.rv-tag-suggestions').forEach(container => {
container.innerHTML = '';
const input = container.parentElement.querySelector('.rv-tags');
cachedTags.forEach(t => {
const chip = document.createElement('button');
chip.type = 'button';
chip.textContent = t.tag + (t.count > 0 ? ' (' + t.count + ')' : '');
chip.style.cssText = 'background:#3A2E10; color:#C8A840; border:1px solid #8B6914; border-radius:3px; padding:3px 8px; font-size:.75rem; font-family:monospace; cursor:pointer; transition:background .15s;';
chip.onmouseenter = () => { chip.style.background = '#5A4A20'; };
chip.onmouseleave = () => { updateChipStyle(chip, input); };
chip.onclick = () => toggleTag(chip, input, t.tag);
// Check if already selected
const current = input.value.split(',').map(s => s.trim().toLowerCase());
if (current.includes(t.tag.toLowerCase())) {
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
}
container.appendChild(chip);
});
});
}
function toggleTag(chip, input, tag) {
const tags = input.value.split(',').map(s => s.trim()).filter(Boolean);
const idx = tags.findIndex(t => t.toLowerCase() === tag.toLowerCase());
if (idx >= 0) {
tags.splice(idx, 1);
chip.style.background = '#3A2E10';
chip.style.color = '#C8A840';
chip.style.borderColor = '#8B6914';
} else {
tags.push(tag);
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
}
input.value = tags.join(', ');
}
function updateChipStyle(chip, input) {
const tag = chip.textContent.replace(/\s*\(\d+\)$/, '');
const current = input.value.split(',').map(s => s.trim().toLowerCase());
if (current.includes(tag.toLowerCase())) {
chip.style.background = '#C1440E';
chip.style.color = '#E8D5B0';
chip.style.borderColor = '#D95A20';
} else {
chip.style.background = '#3A2E10';
chip.style.color = '#C8A840';
chip.style.borderColor = '#8B6914';
}
}
async function loadResources() {
const tbody = document.getElementById('resources-body');
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
try {
const res = await fetch('/api/resources', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="7" class="error">Fehler beim Laden</td></tr>'; return; }
const resources = await res.json();
resourcesLoaded = true;
if (!resources.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Keine Ressourcen vorhanden</td></tr>';
return;
}
tbody.innerHTML = '';
resources.forEach(r => {
const tr = document.createElement('tr');
const tdTitle = document.createElement('td'); tdTitle.textContent = r.title;
const tdFormat = document.createElement('td'); tdFormat.textContent = (r.fileFormat || '').toUpperCase();
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(r.fileSize || 0);
const tdTags = document.createElement('td'); tdTags.textContent = (r.tags || []).join(', ');
const tdAuthor = document.createElement('td'); tdAuthor.textContent = r.author || '';
const tdDate = document.createElement('td');
tdDate.textContent = r.createdAt ? new Date(r.createdAt).toLocaleDateString('de-DE') : '';
const tdAct = document.createElement('td'); tdAct.className = 'actions';
const btnDl = document.createElement('button'); btnDl.className = 'secondary'; btnDl.textContent = 'Download';
btnDl.onclick = () => { window.open('/api/resources/' + r.guid + '/download?token=' + accessToken, '_blank'); };
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen';
btnDel.onclick = () => deleteResource(r.guid, r.title);
tdAct.append(btnDl, btnDel);
tr.append(tdTitle, tdFormat, tdSize, tdTags, tdAuthor, tdDate, tdAct);
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
}
}
async function uploadResource() {
const err = document.getElementById('resource-error');
err.style.display = 'none';
const title = document.getElementById('res-title').value.trim();
const fileInput = document.getElementById('res-file');
if (!title) { err.textContent = 'Titel ist erforderlich'; err.style.display = 'block'; return; }
if (!fileInput.files.length) { err.textContent = 'Datei ist erforderlich'; err.style.display = 'block'; return; }
const file = fileInput.files[0];
const ext = file.name.split('.').pop().toLowerCase();
const mimeMap = { pdf: 'application/pdf', epub: 'application/epub+zip', zip: 'application/zip', '7z': 'application/x-7z-compressed' };
const metadata = {
guid: '',
title: title,
description: document.getElementById('res-description').value.trim(),
tags: document.getElementById('res-tags').value.split(',').map(t => t.trim()).filter(Boolean),
fileFormat: ext,
mimeType: mimeMap[ext] || 'application/octet-stream',
fileSize: 0,
releaseDate: document.getElementById('res-releaseDate').value || null,
createdAt: 0,
updatedAt: 0,
author: document.getElementById('res-author').value.trim() || null,
language: document.getElementById('res-language').value.trim() || null,
edition: document.getElementById('res-edition').value.trim() || null,
downloadUrl: ''
};
const formData = new FormData();
formData.append('metadata', JSON.stringify(metadata));
formData.append('file', file);
try {
const res = await fetch('/api/admin/resources', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken },
body: formData
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
err.textContent = body.message || 'Upload fehlgeschlagen (HTTP ' + res.status + ')';
err.style.display = 'block';
return;
}
// Reset form
document.getElementById('res-title').value = '';
document.getElementById('res-description').value = '';
document.getElementById('res-author').value = '';
document.getElementById('res-language').value = '';
document.getElementById('res-edition').value = '';
document.getElementById('res-releaseDate').value = '';
document.getElementById('res-tags').value = '';
fileInput.value = '';
toggleResourceForm();
loadResources();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
async function deleteResource(guid, title) {
if (!confirm('Ressource "' + title + '" wirklich löschen?')) return;
await fetch('/api/admin/resources/' + guid, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadResources();
}
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];