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.client.websockets)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.compose.markdown)
|
||||
|
||||
// Shared module
|
||||
implementation(project(":shared"))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import de.bollwerk.app.ui.camera.CameraScreen
|
||||
import de.bollwerk.app.ui.category.CategoryListScreen
|
||||
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.messaging.ChatScreen
|
||||
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.settings.SettingsScreen
|
||||
import de.bollwerk.app.ui.warnings.WarningsScreen
|
||||
|
|
@ -101,7 +103,19 @@ internal fun BollwerkNavGraph(
|
|||
}
|
||||
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -37,4 +37,7 @@ internal sealed interface Screen {
|
|||
|
||||
@Serializable
|
||||
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)
|
||||
@Composable
|
||||
internal fun ResourceListScreen(
|
||||
onResourceClick: (String) -> Unit = {},
|
||||
viewModel: ResourceListViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -218,6 +219,7 @@ internal fun ResourceListScreen(
|
|||
ResourceCard(
|
||||
resource = resource,
|
||||
downloadState = downloadState,
|
||||
onClick = { onResourceClick(resource.guid) },
|
||||
onDownloadClick = {
|
||||
viewModel.downloadAndOpen(
|
||||
guid = resource.guid,
|
||||
|
|
@ -240,10 +242,12 @@ internal fun ResourceListScreen(
|
|||
private fun ResourceCard(
|
||||
resource: ResourceDto,
|
||||
downloadState: DownloadState,
|
||||
onClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onClick
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ flyway = "9.22.3"
|
|||
pdfbox = "3.0.4"
|
||||
commonsCompress = "1.27.1"
|
||||
metadataExtractor = "2.19.0"
|
||||
composeMarkdown = "0.5.4"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" }
|
||||
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" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
|
||||
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 {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue