Initial implementation of songbook toolset
Kotlin/JVM multi-module project for generating a scout songbook PDF from ChordPro-format text files. Includes ChordPro parser, layout engine with greedy spread packing for double-page songs, OpenPDF renderer, CLI (Clikt), Compose Desktop GUI, and 5 sample songs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
gui/build.gradle.kts
Normal file
20
gui/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
id("songbook-conventions")
|
||||
id("org.jetbrains.compose")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":app"))
|
||||
implementation(project(":model"))
|
||||
implementation(project(":parser"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "de.pfadfinder.songbook.gui.AppKt"
|
||||
}
|
||||
}
|
||||
347
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
347
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
@@ -0,0 +1,347 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import de.pfadfinder.songbook.app.BuildResult
|
||||
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||
import de.pfadfinder.songbook.parser.ChordProParser
|
||||
import de.pfadfinder.songbook.parser.ValidationError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Songbook Builder"
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
data class SongEntry(val fileName: String, val title: String)
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
var projectPath by remember { mutableStateOf("") }
|
||||
var songs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
val songsDir = if (configFile.exists()) {
|
||||
try {
|
||||
val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile)
|
||||
File(projectDir, config.songs.directory)
|
||||
} catch (_: Exception) {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
} else {
|
||||
File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
songs = songFiles.mapNotNull { file ->
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
|
||||
} catch (_: Exception) {
|
||||
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Project directory selection
|
||||
Text(
|
||||
text = "Songbook Builder",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text("Projektverzeichnis:", fontWeight = FontWeight.Medium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = projectPath,
|
||||
onValueChange = {
|
||||
projectPath = it
|
||||
loadSongs(it)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text("Pfad zum Projektverzeichnis...") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
dialogTitle = "Projektverzeichnis auswählen"
|
||||
if (projectPath.isNotBlank()) {
|
||||
currentDirectory = File(projectPath)
|
||||
}
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
projectPath = chooser.selectedFile.absolutePath
|
||||
loadSongs(projectPath)
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("Durchsuchen...")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Song list
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswählen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(songs) { song ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) {
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO))
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build()
|
||||
} catch (e: Exception) {
|
||||
BuildResult(
|
||||
success = false,
|
||||
errors = listOf(
|
||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
lastBuildResult = result
|
||||
statusMessages = if (result.success) {
|
||||
listOf(
|
||||
StatusMessage(
|
||||
"Buch erfolgreich erstellt! ${result.songCount} Lieder, ${result.pageCount} Seiten.",
|
||||
MessageType.SUCCESS
|
||||
),
|
||||
StatusMessage(
|
||||
"Ausgabedatei: ${result.outputFile?.absolutePath ?: "unbekannt"}",
|
||||
MessageType.INFO
|
||||
)
|
||||
)
|
||||
} else {
|
||||
result.errors.map { error ->
|
||||
val location = buildString {
|
||||
if (error.file != null) append(error.file)
|
||||
if (error.line != null) append(":${error.line}")
|
||||
}
|
||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Buch erstellen")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO))
|
||||
scope.launch {
|
||||
val errors = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).validate()
|
||||
} catch (e: Exception) {
|
||||
listOf(
|
||||
ValidationError(null, null, "Unerwarteter Fehler: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
statusMessages = if (errors.isEmpty()) {
|
||||
listOf(StatusMessage("Validierung erfolgreich! Keine Fehler gefunden.", MessageType.SUCCESS))
|
||||
} else {
|
||||
errors.map { error ->
|
||||
val location = buildString {
|
||||
if (error.file != null) append(error.file)
|
||||
if (error.line != null) append(":${error.line}")
|
||||
}
|
||||
val prefix = if (location.isNotEmpty()) "[$location] " else ""
|
||||
StatusMessage("$prefix${error.message}", MessageType.ERROR)
|
||||
}
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Validieren")
|
||||
}
|
||||
|
||||
if (lastBuildResult?.success == true && lastBuildResult?.outputFile != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
lastBuildResult?.outputFile?.let { file ->
|
||||
try {
|
||||
Desktop.getDesktop().open(file)
|
||||
} catch (e: Exception) {
|
||||
statusMessages = statusMessages + StatusMessage(
|
||||
"PDF konnte nicht geöffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF öffnen")
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).align(Alignment.CenterVertically),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Status/log area
|
||||
Text("Status:", fontWeight = FontWeight.Medium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
) {
|
||||
val logListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = logListState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
if (statusMessages.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Bereit.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(statusMessages) { msg ->
|
||||
Text(
|
||||
text = msg.text,
|
||||
color = when (msg.type) {
|
||||
MessageType.ERROR -> MaterialTheme.colors.error
|
||||
MessageType.SUCCESS -> Color(0xFF2E7D32)
|
||||
MessageType.INFO -> Color.Unspecified
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(logListState)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MessageType {
|
||||
INFO, SUCCESS, ERROR
|
||||
}
|
||||
|
||||
data class StatusMessage(val text: String, val type: MessageType)
|
||||
Reference in New Issue
Block a user