feat(app): add ResourceDetailScreen with markdown rendering and clickable cards
- ResourceDetailScreen: full metadata display, download button, markdown description - ResourceListScreen: cards now clickable, navigates to detail view - Navigation: added ResourceDetail route with guid parameter - Dependencies: compose-markdown 0.5.4 (JitPack), added toRoute import - settings.gradle.kts: added JitPack repository
This commit is contained in:
parent
506374f35b
commit
26117ac23f
8 changed files with 598 additions and 2 deletions
|
|
@ -95,6 +95,9 @@ dependencies {
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|
||||||
|
// Markdown rendering
|
||||||
|
implementation(libs.compose.markdown)
|
||||||
|
|
||||||
// Shared module
|
// Shared module
|
||||||
implementation(project(":shared"))
|
implementation(project(":shared"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.toRoute
|
||||||
import de.bollwerk.app.ui.camera.CameraScreen
|
import de.bollwerk.app.ui.camera.CameraScreen
|
||||||
import de.bollwerk.app.ui.category.CategoryListScreen
|
import de.bollwerk.app.ui.category.CategoryListScreen
|
||||||
import de.bollwerk.app.ui.dashboard.DashboardScreen
|
import de.bollwerk.app.ui.dashboard.DashboardScreen
|
||||||
|
|
@ -13,6 +14,7 @@ import de.bollwerk.app.ui.item.ItemListScreen
|
||||||
import de.bollwerk.app.ui.location.LocationListScreen
|
import de.bollwerk.app.ui.location.LocationListScreen
|
||||||
import de.bollwerk.app.ui.messaging.ChatScreen
|
import de.bollwerk.app.ui.messaging.ChatScreen
|
||||||
import de.bollwerk.app.ui.messaging.UserListScreen
|
import de.bollwerk.app.ui.messaging.UserListScreen
|
||||||
|
import de.bollwerk.app.ui.resources.ResourceDetailScreen
|
||||||
import de.bollwerk.app.ui.resources.ResourceListScreen
|
import de.bollwerk.app.ui.resources.ResourceListScreen
|
||||||
import de.bollwerk.app.ui.settings.SettingsScreen
|
import de.bollwerk.app.ui.settings.SettingsScreen
|
||||||
import de.bollwerk.app.ui.warnings.WarningsScreen
|
import de.bollwerk.app.ui.warnings.WarningsScreen
|
||||||
|
|
@ -101,7 +103,19 @@ internal fun BollwerkNavGraph(
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.ResourceList> {
|
composable<Screen.ResourceList> {
|
||||||
ResourceListScreen()
|
ResourceListScreen(
|
||||||
|
onResourceClick = { guid ->
|
||||||
|
navController.navigate(Screen.ResourceDetail(guid = guid))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<Screen.ResourceDetail> { backStackEntry ->
|
||||||
|
val detail = backStackEntry.toRoute<Screen.ResourceDetail>()
|
||||||
|
ResourceDetailScreen(
|
||||||
|
guid = detail.guid,
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Screen.UserList> {
|
composable<Screen.UserList> {
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,7 @@ internal sealed interface Screen {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object ResourceList : Screen
|
data object ResourceList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ResourceDetail(val guid: String) : Screen
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
package de.bollwerk.app.ui.resources
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.bollwerk.shared.model.ResourceDto
|
||||||
|
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ResourceDetailScreen(
|
||||||
|
guid: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: ResourceListViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val resource = uiState.resources.find { it.guid == guid }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val downloadState = uiState.downloadStates[guid] ?: DownloadState.Idle
|
||||||
|
|
||||||
|
// Handle download success
|
||||||
|
if (downloadState is DownloadState.Success) {
|
||||||
|
LaunchedEffect(downloadState) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", downloadState.file)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, downloadState.mimeType)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
snackbarHostState.showSnackbar("Keine App zum Öffnen dieser Datei gefunden")
|
||||||
|
}
|
||||||
|
viewModel.clearDownloadState(guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (downloadState is DownloadState.Error) {
|
||||||
|
LaunchedEffect(downloadState) {
|
||||||
|
snackbarHostState.showSnackbar("Download fehlgeschlagen: ${downloadState.message}")
|
||||||
|
viewModel.clearDownloadState(guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(resource?.title ?: "Ressource") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
if (resource == null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text("Ressource nicht gefunden", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
return@Scaffold
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Header with format badge and download button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
FormatBadgeLarge(resource.fileFormat)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.downloadAndOpen(
|
||||||
|
guid = resource.guid,
|
||||||
|
mimeType = resource.mimeType,
|
||||||
|
fileFormat = resource.fileFormat,
|
||||||
|
context = context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = downloadState !is DownloadState.Loading
|
||||||
|
) {
|
||||||
|
if (downloadState is DownloadState.Loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text("Herunterladen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Metadata card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
MetadataRow("Titel", resource.title)
|
||||||
|
resource.author?.takeIf { it.isNotBlank() }?.let { MetadataRow("Autor", it) }
|
||||||
|
resource.language?.takeIf { it.isNotBlank() }?.let { MetadataRow("Sprache", it) }
|
||||||
|
resource.edition?.takeIf { it.isNotBlank() }?.let { MetadataRow("Edition", it) }
|
||||||
|
resource.releaseDate?.takeIf { it.isNotBlank() }?.let { MetadataRow("Erscheinungsdatum", it) }
|
||||||
|
MetadataRow("Format", resource.fileFormat.uppercase())
|
||||||
|
MetadataRow("MIME-Typ", resource.mimeType)
|
||||||
|
MetadataRow("Größe", formatFileSize(resource.fileSize))
|
||||||
|
MetadataRow("Hochgeladen", formatTimestamp(resource.createdAt))
|
||||||
|
MetadataRow("Aktualisiert", formatTimestamp(resource.updatedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (resource.tags.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Tags", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
resource.tags.forEach { tag ->
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = tag,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (Markdown)
|
||||||
|
if (resource.description.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Beschreibung", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
MarkdownText(
|
||||||
|
markdown = resource.description,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetadataRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(0.35f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(0.65f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FormatBadgeLarge(format: String) {
|
||||||
|
val color = when (format.lowercase()) {
|
||||||
|
"epub" -> MaterialTheme.colorScheme.primary
|
||||||
|
"pdf" -> androidx.compose.ui.graphics.Color(0xFFE53935)
|
||||||
|
"zip" -> androidx.compose.ui.graphics.Color.Gray
|
||||||
|
"7z" -> androidx.compose.ui.graphics.Color(0xFFFF8F00)
|
||||||
|
else -> MaterialTheme.colorScheme.tertiary
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = color.copy(alpha = 0.15f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = format.uppercase(),
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTimestamp(millis: Long): String {
|
||||||
|
if (millis == 0L) return "–"
|
||||||
|
val sdf = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFileSize(bytes: Long): String {
|
||||||
|
return if (bytes >= 1024 * 1024) {
|
||||||
|
String.format("%.1f MB", bytes / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
String.format("%.0f KB", bytes / 1024.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,7 @@ import java.io.File
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ResourceListScreen(
|
internal fun ResourceListScreen(
|
||||||
|
onResourceClick: (String) -> Unit = {},
|
||||||
viewModel: ResourceListViewModel = hiltViewModel()
|
viewModel: ResourceListViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -218,6 +219,7 @@ internal fun ResourceListScreen(
|
||||||
ResourceCard(
|
ResourceCard(
|
||||||
resource = resource,
|
resource = resource,
|
||||||
downloadState = downloadState,
|
downloadState = downloadState,
|
||||||
|
onClick = { onResourceClick(resource.guid) },
|
||||||
onDownloadClick = {
|
onDownloadClick = {
|
||||||
viewModel.downloadAndOpen(
|
viewModel.downloadAndOpen(
|
||||||
guid = resource.guid,
|
guid = resource.guid,
|
||||||
|
|
@ -240,10 +242,12 @@ internal fun ResourceListScreen(
|
||||||
private fun ResourceCard(
|
private fun ResourceCard(
|
||||||
resource: ResourceDto,
|
resource: ResourceDto,
|
||||||
downloadState: DownloadState,
|
downloadState: DownloadState,
|
||||||
|
onClick: () -> Unit,
|
||||||
onDownloadClick: () -> Unit
|
onDownloadClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ flyway = "9.22.3"
|
||||||
pdfbox = "3.0.4"
|
pdfbox = "3.0.4"
|
||||||
commonsCompress = "1.27.1"
|
commonsCompress = "1.27.1"
|
||||||
metadataExtractor = "2.19.0"
|
metadataExtractor = "2.19.0"
|
||||||
|
composeMarkdown = "0.5.4"
|
||||||
|
|
||||||
[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" }
|
||||||
|
|
@ -86,6 +87,7 @@ 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" }
|
||||||
|
compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" }
|
||||||
pdfbox = { group = "org.apache.pdfbox", name = "pdfbox", version.ref = "pdfbox" }
|
pdfbox = { group = "org.apache.pdfbox", name = "pdfbox", version.ref = "pdfbox" }
|
||||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
|
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
|
||||||
metadata-extractor = { group = "com.drewnoakes", name = "metadata-extractor", version.ref = "metadataExtractor" }
|
metadata-extractor = { group = "com.drewnoakes", name = "metadata-extractor", version.ref = "metadataExtractor" }
|
||||||
|
|
|
||||||
291
import-books.py
Normal file
291
import-books.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Importiert 10 freie Bücher (Project Gutenberg) in die Bollwerk-Ressourcen.
|
||||||
|
|
||||||
|
Voraussetzungen:
|
||||||
|
- Server läuft unter SERVER_URL
|
||||||
|
- Admin-Credentials als Env-Vars: BOLLWERK_ADMIN_USER, BOLLWERK_ADMIN_PASS
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
python import-books.py
|
||||||
|
python import-books.py --dry-run (nur Download, kein Upload)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from io import BytesIO
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
SERVER_URL = "https://bollwerk.online"
|
||||||
|
|
||||||
|
# --- 10 ausgewählte freie Bücher (Project Gutenberg, Public Domain) ---
|
||||||
|
BOOKS = [
|
||||||
|
{
|
||||||
|
"gutenberg_id": 132,
|
||||||
|
"title": "The Art of War",
|
||||||
|
"author": "Sun Tzu",
|
||||||
|
"description": "Der älteste bekannte Militärstrategie-Text. Behandelt Taktik, Täuschung, Anpassungsfähigkeit und die Kunst, Konflikte ohne Kampf zu gewinnen.",
|
||||||
|
"tags": ["nachschlagewerk", "handbuch"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "-500",
|
||||||
|
"edition": "Lionel Giles Translation, 1910",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 2680,
|
||||||
|
"title": "Meditations",
|
||||||
|
"author": "Marcus Aurelius",
|
||||||
|
"description": "Persönliche Aufzeichnungen des römischen Kaisers über stoische Philosophie, Selbstdisziplin und innere Ruhe – ein zeitloser Leitfaden für schwierige Zeiten.",
|
||||||
|
"tags": ["nachschlagewerk"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "180",
|
||||||
|
"edition": "George Long Translation, 1862",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 84,
|
||||||
|
"title": "Frankenstein; or, The Modern Prometheus",
|
||||||
|
"author": "Mary Wollstonecraft Shelley",
|
||||||
|
"description": "Der Urtext der Science-Fiction: Victor Frankenstein erschafft künstliches Leben und muss sich den Konsequenzen seiner Hybris stellen.",
|
||||||
|
"tags": ["novel"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1818-01-01",
|
||||||
|
"edition": "1831 Revised Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 345,
|
||||||
|
"title": "Dracula",
|
||||||
|
"author": "Bram Stoker",
|
||||||
|
"description": "Der Briefroman über den transsilvanischen Vampirgrafen – Grundlage für unzählige Horror-RPG-Szenarien und der wohl einflussreichste Horrorroman überhaupt.",
|
||||||
|
"tags": ["novel", "abenteuer"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1897-05-26",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 8492,
|
||||||
|
"title": "The King in Yellow",
|
||||||
|
"author": "Robert W. Chambers",
|
||||||
|
"description": "Kurzgeschichtensammlung über ein übernatürliches Theaterstück, das Wahnsinn bringt. Inspiration für Lovecrafts Mythos und das Call-of-Cthulhu-Rollenspiel.",
|
||||||
|
"tags": ["novel", "quellenbuch"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1895-01-01",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 120,
|
||||||
|
"title": "Treasure Island",
|
||||||
|
"author": "Robert Louis Stevenson",
|
||||||
|
"description": "Der klassische Piraten-Abenteuerroman: Jim Hawkins, Long John Silver und die Jagd nach Captain Flints vergrabenen Schatz – perfekte Vorlage für Rollenspiel-Szenarien.",
|
||||||
|
"tags": ["novel", "abenteuer"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1883-11-14",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 1661,
|
||||||
|
"title": "The Adventures of Sherlock Holmes",
|
||||||
|
"author": "Arthur Conan Doyle",
|
||||||
|
"description": "Zwölf Detektivgeschichten um den genialen Sherlock Holmes und Dr. Watson. Meisterhafte Rätsel, die sich hervorragend als Inspiration für Investigativ-Abenteuer eignen.",
|
||||||
|
"tags": ["novel", "abenteuer"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1892-10-14",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 1232,
|
||||||
|
"title": "The Prince",
|
||||||
|
"author": "Niccolò Machiavelli",
|
||||||
|
"description": "Die berühmte Abhandlung über politische Macht, Herrschaft und Staatskunst – unverzichtbar als Hintergrundlektüre für Intrigen-Kampagnen.",
|
||||||
|
"tags": ["nachschlagewerk", "handbuch"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1532-01-01",
|
||||||
|
"edition": "W. K. Marriott Translation, 1908",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 43,
|
||||||
|
"title": "The Strange Case of Dr. Jekyll and Mr. Hyde",
|
||||||
|
"author": "Robert Louis Stevenson",
|
||||||
|
"description": "Die Novelle über die dunkle Doppelnatur des Menschen – ein Meisterwerk des viktorianischen Horrors und Inspiration für zahllose RPG-Antagonisten.",
|
||||||
|
"tags": ["novel"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1886-01-05",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gutenberg_id": 215,
|
||||||
|
"title": "The Call of the Wild",
|
||||||
|
"author": "Jack London",
|
||||||
|
"description": "Die Geschichte des Hundes Buck, der im Yukon zum Anführer eines Wolfsrudels wird. Ein packender Überlebensroman über Instinkt, Wildnis und Anpassung.",
|
||||||
|
"tags": ["novel", "abenteuer"],
|
||||||
|
"language": "en",
|
||||||
|
"release_date": "1903-01-01",
|
||||||
|
"edition": "First Edition",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RESET = "\033[0m"
|
||||||
|
GREEN = "\033[92m"
|
||||||
|
YELLOW = "\033[93m"
|
||||||
|
RED = "\033[91m"
|
||||||
|
CYAN = "\033[96m"
|
||||||
|
|
||||||
|
def ok(msg): print(f"{GREEN}[OK] {msg}{RESET}", flush=True)
|
||||||
|
def fail(msg): print(f"{RED}[!!] {msg}{RESET}", flush=True)
|
||||||
|
def step(msg): print(f"\n{YELLOW}{msg}{RESET}", flush=True)
|
||||||
|
def info(msg): print(f" {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def login(username: str, password: str) -> str:
|
||||||
|
"""Login and return JWT access token."""
|
||||||
|
payload = json.dumps({"username": username, "password": password}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{SERVER_URL}/api/auth/login",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return data["accessToken"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode() if e.fp else ""
|
||||||
|
fail(f"Login fehlgeschlagen: {e.code} {body}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def download_epub(gutenberg_id: int) -> bytes:
|
||||||
|
"""Download ePub from Project Gutenberg (prefer lightweight no-images version)."""
|
||||||
|
urls = [
|
||||||
|
f"https://www.gutenberg.org/ebooks/{gutenberg_id}.epub.noimages",
|
||||||
|
f"https://www.gutenberg.org/ebooks/{gutenberg_id}.epub3.images",
|
||||||
|
f"https://www.gutenberg.org/cache/epub/{gutenberg_id}/pg{gutenberg_id}.epub",
|
||||||
|
]
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "BollwerkImporter/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
if len(data) > 1000: # sanity check
|
||||||
|
return data
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"Konnte Gutenberg #{gutenberg_id} nicht herunterladen")
|
||||||
|
|
||||||
|
|
||||||
|
def upload_resource(token: str, book: dict, file_bytes: bytes) -> dict:
|
||||||
|
"""Upload resource via multipart POST to admin API."""
|
||||||
|
guid = str(uuid.uuid4())
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"guid": guid,
|
||||||
|
"title": book["title"],
|
||||||
|
"description": book["description"],
|
||||||
|
"tags": book["tags"],
|
||||||
|
"fileFormat": "epub",
|
||||||
|
"mimeType": "application/epub+zip",
|
||||||
|
"fileSize": len(file_bytes),
|
||||||
|
"releaseDate": book.get("release_date"),
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
"author": book.get("author"),
|
||||||
|
"language": book.get("language", "en"),
|
||||||
|
"edition": book.get("edition"),
|
||||||
|
"downloadUrl": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build multipart/form-data manually
|
||||||
|
boundary = f"----BollwerkBoundary{uuid.uuid4().hex[:16]}"
|
||||||
|
body = bytearray()
|
||||||
|
|
||||||
|
# Part 1: metadata JSON
|
||||||
|
body += f"--{boundary}\r\n".encode()
|
||||||
|
body += b'Content-Disposition: form-data; name="metadata"\r\n'
|
||||||
|
body += b"Content-Type: application/json\r\n\r\n"
|
||||||
|
body += json.dumps(metadata).encode()
|
||||||
|
body += b"\r\n"
|
||||||
|
|
||||||
|
# Part 2: file
|
||||||
|
body += f"--{boundary}\r\n".encode()
|
||||||
|
body += f'Content-Disposition: form-data; name="file"; filename="{guid}.epub"\r\n'.encode()
|
||||||
|
body += b"Content-Type: application/epub+zip\r\n\r\n"
|
||||||
|
body += file_bytes
|
||||||
|
body += b"\r\n"
|
||||||
|
|
||||||
|
# End boundary
|
||||||
|
body += f"--{boundary}--\r\n".encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{SERVER_URL}/api/admin/resources",
|
||||||
|
data=bytes(body),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode() if e.fp else ""
|
||||||
|
raise RuntimeError(f"Upload fehlgeschlagen: {e.code} {error_body}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
|
||||||
|
step("=== Bollwerk Bücher-Import (10 freie Klassiker) ===")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
username = os.environ.get("BOLLWERK_ADMIN_USER", "")
|
||||||
|
password = os.environ.get("BOLLWERK_ADMIN_PASS", "")
|
||||||
|
if not username or not password:
|
||||||
|
fail("Setze BOLLWERK_ADMIN_USER und BOLLWERK_ADMIN_PASS als Env-Vars")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
step("1/3 Login als Admin...")
|
||||||
|
token = login(username, password)
|
||||||
|
ok(f"Eingeloggt als '{username}'")
|
||||||
|
else:
|
||||||
|
token = ""
|
||||||
|
info("DRY-RUN: Kein Login, kein Upload")
|
||||||
|
|
||||||
|
step("2/3 Bücher herunterladen...")
|
||||||
|
downloads = []
|
||||||
|
for i, book in enumerate(BOOKS, 1):
|
||||||
|
info(f" [{i:2d}/10] {book['title']} (Gutenberg #{book['gutenberg_id']})...")
|
||||||
|
try:
|
||||||
|
data = download_epub(book["gutenberg_id"])
|
||||||
|
downloads.append((book, data))
|
||||||
|
ok(f" {book['title']} – {len(data) / 1024:.0f} KB")
|
||||||
|
except RuntimeError as e:
|
||||||
|
fail(f" {e}")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
step("3/3 Upload auf Bollwerk-Server...")
|
||||||
|
success = 0
|
||||||
|
for i, (book, data) in enumerate(downloads, 1):
|
||||||
|
info(f" [{i:2d}/{len(downloads)}] {book['title']}...")
|
||||||
|
try:
|
||||||
|
result = upload_resource(token, book, data)
|
||||||
|
ok(f" {book['title']} → guid={result['guid']}")
|
||||||
|
success += 1
|
||||||
|
except RuntimeError as e:
|
||||||
|
fail(f" {e}")
|
||||||
|
time.sleep(0.5) # rate limiting
|
||||||
|
|
||||||
|
step(f"Fertig: {success}/{len(downloads)} Bücher erfolgreich importiert.")
|
||||||
|
else:
|
||||||
|
step(f"DRY-RUN abgeschlossen: {len(downloads)} Bücher heruntergeladen, 0 hochgeladen.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -16,6 +16,7 @@ dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue