diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fef3e6f..48c33bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt b/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt index a05c79f..5eec009 100644 --- a/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt +++ b/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt @@ -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 { - ResourceListScreen() + ResourceListScreen( + onResourceClick = { guid -> + navController.navigate(Screen.ResourceDetail(guid = guid)) + } + ) + } + + composable { backStackEntry -> + val detail = backStackEntry.toRoute() + ResourceDetailScreen( + guid = detail.guid, + onNavigateBack = { navController.popBackStack() } + ) } composable { diff --git a/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt b/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt index 637f3d1..460678b 100644 --- a/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt @@ -37,4 +37,7 @@ internal sealed interface Screen { @Serializable data object ResourceList : Screen + + @Serializable + data class ResourceDetail(val guid: String) : Screen } diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceDetailScreen.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceDetailScreen.kt new file mode 100644 index 0000000..bcd78e9 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceDetailScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt index 0684f83..8401d86 100644 --- a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt @@ -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( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c6c7be..81923f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/import-books.py b/import-books.py new file mode 100644 index 0000000..6a6af1a --- /dev/null +++ b/import-books.py @@ -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() diff --git a/settings.gradle.kts b/settings.gradle.kts index 15ad248..eb1939d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }