Compare commits
1 Commits
latex-rewr
...
13d1d4525c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d1d4525c |
37
.gitignore
vendored
@@ -1,29 +1,22 @@
|
|||||||
# LaTeX build artifacts
|
# Gradle
|
||||||
*.aux
|
.gradle/
|
||||||
*.log
|
build/
|
||||||
*.out
|
buildSrc/build/
|
||||||
*.toc
|
|
||||||
*.fls
|
|
||||||
*.fdb_latexmk
|
|
||||||
*.synctex.gz
|
|
||||||
*.synctex(busy)
|
|
||||||
*.sxd
|
|
||||||
*.sxc
|
|
||||||
|
|
||||||
# Output directory
|
# IDE
|
||||||
output/
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Editor files
|
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.vscode/
|
.vscode/
|
||||||
*~
|
|
||||||
*.swp
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Output
|
||||||
|
output/
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
*.class
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
__pycache__/
|
|
||||||
|
|||||||
118
CLAUDE.md
@@ -2,83 +2,79 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Build Commands
|
## Build & Test Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the songbook PDF (two-pass for TOC)
|
# Build everything
|
||||||
make
|
gradle build
|
||||||
|
|
||||||
# Remove auxiliary files
|
# Run all tests
|
||||||
make clean
|
gradle test
|
||||||
|
|
||||||
# Remove everything including PDF
|
# Run tests for a specific module
|
||||||
make distclean
|
gradle :parser:test
|
||||||
|
gradle :layout:test
|
||||||
|
gradle :renderer-pdf:test
|
||||||
|
gradle :app:test
|
||||||
|
|
||||||
|
# Run a single test class
|
||||||
|
gradle :parser:test --tests ChordProParserTest
|
||||||
|
|
||||||
|
# Run a single test method
|
||||||
|
gradle :parser:test --tests "ChordProParserTest.parse complete song"
|
||||||
|
|
||||||
|
# Build and run CLI
|
||||||
|
gradle :cli:run --args="build -d /path/to/project"
|
||||||
|
gradle :cli:run --args="validate -d /path/to/project"
|
||||||
|
|
||||||
|
# Launch GUI
|
||||||
|
gradle :gui:run
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires LuaLaTeX (TeX Live) and the `leadsheets` package.
|
Requires Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
|
||||||
|
|
||||||
## Project Structure
|
## Architecture
|
||||||
|
|
||||||
|
**Pipeline:** Parse → Measure → Paginate → Render
|
||||||
|
|
||||||
|
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||||
|
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||||
|
2. `ChordProParser` reads `.chopro` files → `Song` objects
|
||||||
|
3. `Validator` checks config and songs
|
||||||
|
4. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||||
|
5. `TocGenerator` estimates TOC page count and creates entries
|
||||||
|
6. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||||
|
7. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||||
|
|
||||||
|
**Module dependency graph:**
|
||||||
```
|
```
|
||||||
songbook.tex # Main document (title page, TOC, song inputs)
|
model ← parser
|
||||||
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
model ← layout
|
||||||
songs/ # One .tex file per song
|
model ← renderer-pdf
|
||||||
fonts/ # Font files (UnifrakturMaguntia for titles)
|
parser, layout, renderer-pdf ← app
|
||||||
images/ # Filler images (empty for now)
|
app ← cli (Clikt)
|
||||||
Makefile # Build rules (lualatex, two passes)
|
app, parser ← gui (Compose Desktop)
|
||||||
output/ # Generated PDF (gitignored)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
`model` is the foundation with no dependencies — all data classes, the `FontMetrics` interface, and the `BookRenderer` interface live here. The `FontMetrics` abstraction decouples layout from rendering: `PdfFontMetrics` is the real implementation (in renderer-pdf), `StubFontMetrics` is used in layout tests.
|
||||||
|
|
||||||
Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
|
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this.
|
||||||
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
|
||||||
- Chords above lyrics in regular weight, black
|
## Key Types
|
||||||
- No verse labels (verses separated by blank lines)
|
|
||||||
- Metadata (Worte/Weise) at bottom of each song page
|
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
||||||
- Reference book cross-references (MO, PfLB) in footer
|
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
||||||
- Each song starts on a new page
|
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||||
- A5 twoside format with page numbers at bottom-outer
|
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||||
|
|
||||||
## Song Format
|
## Song Format
|
||||||
|
|
||||||
Each song uses the `leadsheets` `song` environment:
|
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||||
|
|
||||||
```latex
|
## Test Patterns
|
||||||
\begin{song}{
|
|
||||||
title = Song Title,
|
|
||||||
lyrics = Lyricist,
|
|
||||||
composer = Composer,
|
|
||||||
key = G,
|
|
||||||
mundorgel = 42,
|
|
||||||
pfadfinderliederbuch = 118,
|
|
||||||
note = {Optional note text.},
|
|
||||||
}
|
|
||||||
|
|
||||||
\begin{verse}
|
Tests use `kotlin.test` annotations with Kotest assertions (`shouldBe`, `shouldHaveSize`, etc.) on JUnit 5. Layout tests use `StubFontMetrics` to avoid PDF font dependencies. App integration tests create temp directories with song files and config.
|
||||||
\chord{G}Lyrics with \chord{D}chords above. \\
|
|
||||||
Next \chord{C}line here.
|
|
||||||
\end{verse}
|
|
||||||
|
|
||||||
\begin{verse}
|
## Package
|
||||||
Second verse without chords (or with).
|
|
||||||
\end{verse}
|
|
||||||
|
|
||||||
\end{song}
|
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
||||||
```
|
|
||||||
|
|
||||||
**Important constraints:**
|
|
||||||
- Use `\\` for line breaks within verses (not blank lines)
|
|
||||||
- Never place two `\chord{}` commands without a space between them — split compound words with a hyphen: `\chord{D}Abend- \chord{A}zeit.`
|
|
||||||
- Custom properties: `alias`, `note`, `mundorgel`, `pfadfinderliederbuch`
|
|
||||||
- Verse types: `verse` (no label), `verse*` (for custom-labeled sections like Kanon, Ref.)
|
|
||||||
- `musicsymbols` library skipped (requires `musix11` font not installed)
|
|
||||||
|
|
||||||
## Style Details (songbook-style.sty)
|
|
||||||
|
|
||||||
- Page geometry: A5, margins (top 15mm, bottom 20mm, inner 20mm, outer 12mm)
|
|
||||||
- Body font: TeX Gyre Heros (Helvetica clone)
|
|
||||||
- Title font: UnifrakturMaguntia (Fraktur/blackletter, from `fonts/` directory)
|
|
||||||
- Chord format: small, regular weight, black
|
|
||||||
- Song title template: Fraktur title only (metadata rendered at bottom via `after-song` hook)
|
|
||||||
- Reference style based on Carmina Leonis (Pfadfinder scout songbook)
|
|
||||||
|
|||||||
36
Makefile
@@ -1,36 +0,0 @@
|
|||||||
MAIN = songbook
|
|
||||||
ENGINE = lualatex
|
|
||||||
OUTDIR = output
|
|
||||||
TEXENV = TEXINPUTS=.:$(shell pwd):$(shell pwd)/$(OUTDIR):
|
|
||||||
FLAGS = --output-directory=$(OUTDIR) --interaction=nonstopmode
|
|
||||||
|
|
||||||
.PHONY: all clean distclean
|
|
||||||
|
|
||||||
all: $(OUTDIR)/$(MAIN).pdf
|
|
||||||
|
|
||||||
$(OUTDIR):
|
|
||||||
mkdir -p $(OUTDIR)
|
|
||||||
|
|
||||||
# Run until page references stabilize (max 5 passes).
|
|
||||||
# The .songtoc file needs: pass 1 to write, pass 2 to read into TOC,
|
|
||||||
# pass 3+ to stabilize page numbers after TOC page count changes.
|
|
||||||
$(OUTDIR)/$(MAIN).pdf: $(MAIN).tex songbook-style.sty songs/*.tex | $(OUTDIR)
|
|
||||||
@for i in 1 2 3 4 5; do \
|
|
||||||
echo "=== LaTeX pass $$i ==="; \
|
|
||||||
$(TEXENV) $(ENGINE) $(FLAGS) $(MAIN).tex || true; \
|
|
||||||
if [ -f $(OUTDIR)/$(MAIN).songtoc ]; then \
|
|
||||||
python3 -c "import re;lines=open('$(OUTDIR)/$(MAIN).songtoc').readlines();lines.sort(key=lambda l:re.sub(r'[^a-zäöüß ]','',re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l).group(1).lower()) if re.search(r'\{(?:\\\\textit\s*\{)?([^}]+)',l) else '');open('$(OUTDIR)/$(MAIN).songtoc','w').writelines(lines)" ; \
|
|
||||||
fi; \
|
|
||||||
if [ $$i -ge 3 ] && ! grep -q "Rerun" $(OUTDIR)/$(MAIN).log 2>/dev/null; then \
|
|
||||||
echo "=== Stable after $$i passes ==="; \
|
|
||||||
break; \
|
|
||||||
fi; \
|
|
||||||
done
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f $(OUTDIR)/*.aux $(OUTDIR)/*.log $(OUTDIR)/*.out \
|
|
||||||
$(OUTDIR)/*.toc $(OUTDIR)/*.fls $(OUTDIR)/*.fdb_latexmk \
|
|
||||||
$(OUTDIR)/*.sxd $(OUTDIR)/*.sxc $(OUTDIR)/*.songtoc
|
|
||||||
|
|
||||||
distclean: clean
|
|
||||||
rm -f $(OUTDIR)/$(MAIN).pdf
|
|
||||||
298
all-songs.tex
@@ -1,298 +0,0 @@
|
|||||||
% Auto-generated list of all songs (alphabetical order)
|
|
||||||
% Generated by import-songs.py
|
|
||||||
|
|
||||||
\input{songs/abends-gehn-die-liebespaare}
|
|
||||||
\input{songs/abends-treten-elche}
|
|
||||||
\input{songs/abends-wenn-das-tageslicht}
|
|
||||||
\input{songs/ade-nun-zur-guten-nacht}
|
|
||||||
\input{songs/alle-strassen}
|
|
||||||
\input{songs/alle-die-mit-uns-auf-kaperfahrt}
|
|
||||||
\input{songs/allzeit-bereit-bundeslied-der-cpd}
|
|
||||||
\input{songs/almost-heaven}
|
|
||||||
\input{songs/als-wir-nach-frankreich}
|
|
||||||
\input{songs/als-wir-noch-knaben-waren}
|
|
||||||
\input{songs/am-alten-hafen-piratenhafen}
|
|
||||||
\input{songs/am-brunnen-vor-dem-tore}
|
|
||||||
\input{songs/am-ural}
|
|
||||||
\input{songs/am-westermanns-loenstief}
|
|
||||||
\input{songs/an-de-eck}
|
|
||||||
\input{songs/an-land}
|
|
||||||
\input{songs/andre-die-das-land}
|
|
||||||
\input{songs/auf-vielen-strassen}
|
|
||||||
\input{songs/balkanlied}
|
|
||||||
\input{songs/ballade-von-bergen}
|
|
||||||
\input{songs/ballade-von-der-gemeinsamen-zeit-vorspiel-d-a-d-g}
|
|
||||||
\input{songs/banner}
|
|
||||||
\input{songs/bella-ciao}
|
|
||||||
\input{songs/big-bomb-dolly-aus-dover}
|
|
||||||
\input{songs/bin-ja-nur-ein-armer-zigeuner}
|
|
||||||
\input{songs/birkenring}
|
|
||||||
\input{songs/bis-in-die-roten-morgenstunden}
|
|
||||||
\input{songs/brennt-die-sonne}
|
|
||||||
\input{songs/bruder-nun-wird-es-abend}
|
|
||||||
\input{songs/burschen-burschen}
|
|
||||||
\input{songs/buendische-vaganten}
|
|
||||||
\input{songs/buergerlied}
|
|
||||||
\input{songs/come-by-the-hills}
|
|
||||||
\input{songs/das-gotenlied}
|
|
||||||
\input{songs/das-leben}
|
|
||||||
\input{songs/das-lilienbanner}
|
|
||||||
\input{songs/das-schiff-im-nebel}
|
|
||||||
\input{songs/dat-du-min-leewsten-buest}
|
|
||||||
\input{songs/dein-ist-dein-leben-vorspiel-e-c-g-d}
|
|
||||||
\input{songs/der-da-vorn-so-laut}
|
|
||||||
\input{songs/der-geist-der-fahrt}
|
|
||||||
\input{songs/der-geist-ist-mued}
|
|
||||||
\input{songs/der-holzschuhmann}
|
|
||||||
\input{songs/der-kirchenmausrock}
|
|
||||||
\input{songs/der-kleine-troll}
|
|
||||||
\input{songs/der-lang-genug}
|
|
||||||
\input{songs/der-mond-ist-aufgegangen}
|
|
||||||
\input{songs/der-papagei-ein-vogel-ist}
|
|
||||||
\input{songs/der-pfahl}
|
|
||||||
\input{songs/der-rabe}
|
|
||||||
\input{songs/der-tag-begann}
|
|
||||||
\input{songs/der-tod-reit-auf}
|
|
||||||
\input{songs/der-wagen}
|
|
||||||
\input{songs/der-zug-faehrt-auf}
|
|
||||||
\input{songs/die-affen-rasen}
|
|
||||||
\input{songs/die-ballade-vom-roten-haar}
|
|
||||||
\input{songs/die-blauen-dragoner}
|
|
||||||
\input{songs/die-daemmerung-faellt}
|
|
||||||
\input{songs/die-eisenfaust}
|
|
||||||
\input{songs/die-freie-republik}
|
|
||||||
\input{songs/die-gedanken}
|
|
||||||
\input{songs/die-glocken}
|
|
||||||
\input{songs/die-grauen-nebel}
|
|
||||||
\input{songs/die-herren-waren-bei-laune}
|
|
||||||
\input{songs/die-klampfen-erklingen}
|
|
||||||
\input{songs/die-lappen-hoch}
|
|
||||||
\input{songs/die-mazurka}
|
|
||||||
\input{songs/die-nacht-ist-nicht-allein-zum-schlafen-da}
|
|
||||||
\input{songs/die-roten-fahnen}
|
|
||||||
\input{songs/die-sandbank}
|
|
||||||
\input{songs/die-schluchten-des-balkan}
|
|
||||||
\input{songs/die-sonne-geht}
|
|
||||||
\input{songs/die-strasse-gleitet}
|
|
||||||
\input{songs/die-trommel-her}
|
|
||||||
\input{songs/die-weber}
|
|
||||||
\input{songs/die-zunft-der-strassenbrueder}
|
|
||||||
\input{songs/dort-an-dem-ueferchen}
|
|
||||||
\input{songs/drei-rote-pfiffe}
|
|
||||||
\input{songs/drei-tropfen-blut-chume-geselle}
|
|
||||||
\input{songs/du-machst-kleinholz}
|
|
||||||
\input{songs/durch-die-morgenroten-scheiben}
|
|
||||||
\input{songs/daemmert-von-fern}
|
|
||||||
\input{songs/edelweisspiraten}
|
|
||||||
\input{songs/eh-die-sonne}
|
|
||||||
\input{songs/ein-hase-sass-im-tiefen-tal}
|
|
||||||
\input{songs/ein-hotdog-unten-am-hafen}
|
|
||||||
\input{songs/ein-kleiner-matrose}
|
|
||||||
\input{songs/ein-landsknecht}
|
|
||||||
\input{songs/ein-neuer-tag-beginnt}
|
|
||||||
\input{songs/ein-stolzes-schiff}
|
|
||||||
\input{songs/eines-morgens-partisanenlied}
|
|
||||||
\input{songs/eines-morgens-ging}
|
|
||||||
\input{songs/einst-macht-ich}
|
|
||||||
\input{songs/endlich-trocknet-der-landstrasse}
|
|
||||||
\input{songs/endlos-lang}
|
|
||||||
\input{songs/endlos-sind-jene-strassen}
|
|
||||||
\input{songs/ensemble-on-est-mieux-intro-e-a-c-h7}
|
|
||||||
\input{songs/erklingen-leise-lieder}
|
|
||||||
\input{songs/es-dunkelt-schon}
|
|
||||||
\input{songs/es-gibt-nur-wasser}
|
|
||||||
\input{songs/es-ist-an-der-zeit}
|
|
||||||
\input{songs/es-ist-ein-schnitter}
|
|
||||||
\input{songs/es-liegen-drei-glaenzende-kugeln}
|
|
||||||
\input{songs/es-liegt-etwas-auf-den-strassen}
|
|
||||||
\input{songs/es-soll-sich-der-mensch}
|
|
||||||
\input{songs/es-tropft-von-helm-und-saebel}
|
|
||||||
\input{songs/es-war-an-einem-sommertag}
|
|
||||||
\input{songs/es-war-ein-koenig}
|
|
||||||
\input{songs/es-war-in-einer-regennacht}
|
|
||||||
\input{songs/es-wollt-ein-maegdlein}
|
|
||||||
\input{songs/falado}
|
|
||||||
\input{songs/fields-of-athenry}
|
|
||||||
\input{songs/finnlandlied}
|
|
||||||
\input{songs/fordre-niemand}
|
|
||||||
\input{songs/fresenhof}
|
|
||||||
\input{songs/freunde-das-leben-seid-ihr}
|
|
||||||
\input{songs/frueher-da-war-ich}
|
|
||||||
\input{songs/fruehling-dringt-in-den-norden}
|
|
||||||
\input{songs/geburtstagslied}
|
|
||||||
\input{songs/gehe-nicht-o-gregor}
|
|
||||||
\input{songs/gelbe-blaetter-fallen-im-wind}
|
|
||||||
\input{songs/gestern-brueder}
|
|
||||||
\input{songs/gospodar-dein-grossgut}
|
|
||||||
\input{songs/griechischer-fruehling}
|
|
||||||
\input{songs/grosser-gott-wir-loben-dich}
|
|
||||||
\input{songs/graefin-anne}
|
|
||||||
\input{songs/gut-wieder-hier-zu-sein}
|
|
||||||
\input{songs/gute-nacht-kameraden}
|
|
||||||
\input{songs/hans-spielmann}
|
|
||||||
\input{songs/hell-strahlt-die-sonne}
|
|
||||||
\input{songs/heulender-motor}
|
|
||||||
\input{songs/heute-hier}
|
|
||||||
\input{songs/hier-waechst-kein-ahorn}
|
|
||||||
\input{songs/hoch-lebe-der-mann-mit-dem-hut}
|
|
||||||
\input{songs/hochzeit}
|
|
||||||
\input{songs/hohe-tannen}
|
|
||||||
\input{songs/how-many-roads-blowin-in-the-wind}
|
|
||||||
\input{songs/hymn-intro-e-esus4-e-esus4-e}
|
|
||||||
\input{songs/hoerst-du-den-wind-bundeslied-der-esm}
|
|
||||||
\input{songs/ich-kann-dich-sehen}
|
|
||||||
\input{songs/ich-komme-schon}
|
|
||||||
\input{songs/ich-komme-dir-zu-sagen-versprechenslied}
|
|
||||||
\input{songs/ich-moecht-mit-einem-zirkus-ziehn}
|
|
||||||
\input{songs/ich-reise-uebers-gruene-land}
|
|
||||||
\input{songs/ich-und-ein-fass-voller-wein}
|
|
||||||
\input{songs/ich-war-noch-so-jung-bettelvogt}
|
|
||||||
\input{songs/ihr-huebschen-jungen-reiter}
|
|
||||||
\input{songs/ihr-woelfe-kommt-und-schliesst-den-kreis}
|
|
||||||
\input{songs/im-morgennebel}
|
|
||||||
\input{songs/in-dem-dunklem-wald-von-paganovo}
|
|
||||||
\input{songs/in-des-waldes-lola}
|
|
||||||
\input{songs/in-die-sonne}
|
|
||||||
\input{songs/in-junkers-kneipe}
|
|
||||||
\input{songs/islandlied}
|
|
||||||
\input{songs/jalava-lied}
|
|
||||||
\input{songs/jasmin}
|
|
||||||
\input{songs/jauchzende-jungen}
|
|
||||||
\input{songs/jeden-abend-jerchenkow}
|
|
||||||
\input{songs/jenseits-des-tales}
|
|
||||||
\input{songs/jubilaeumslied-der-esm}
|
|
||||||
\input{songs/joerg-von-frundsberg}
|
|
||||||
\input{songs/kaffee-und-karin}
|
|
||||||
\input{songs/kameraden-jagt-die-pferde}
|
|
||||||
\input{songs/kameraden-wann-sehen-wir-uns-wieder}
|
|
||||||
\input{songs/karl-der-kaefer}
|
|
||||||
\input{songs/kein-schoener-land}
|
|
||||||
\input{songs/klingt-ein-lied-durch-die-nacht-piratenlied}
|
|
||||||
\input{songs/kommt-ihr-menschen}
|
|
||||||
\input{songs/komodowaran-intro-ad7g-ad7g}
|
|
||||||
\input{songs/land-der-dunklen-waelder}
|
|
||||||
\input{songs/lasst-die-banner}
|
|
||||||
\input{songs/leave-her-johnny}
|
|
||||||
\input{songs/leut-die-leut}
|
|
||||||
\input{songs/lord-of-the-dance}
|
|
||||||
\input{songs/laender-fahrten-abenteuer}
|
|
||||||
\input{songs/loewen-sind-jetzt-los}
|
|
||||||
\input{songs/man-sagt}
|
|
||||||
\input{songs/meersalz-seht}
|
|
||||||
\input{songs/mein-ganzes-leben}
|
|
||||||
\input{songs/mein-kleines-boot}
|
|
||||||
\input{songs/meine-sonne-will-ich-fragen}
|
|
||||||
\input{songs/michel-warum-weinest-du}
|
|
||||||
\input{songs/miners-song}
|
|
||||||
\input{songs/molly-malone}
|
|
||||||
\input{songs/moorsoldaten}
|
|
||||||
\input{songs/maedchen-maenner-meister}
|
|
||||||
\input{songs/nacht-in-portugal}
|
|
||||||
\input{songs/nachts-auf-dem-dorfplatz}
|
|
||||||
\input{songs/nachts-steht-hunger}
|
|
||||||
\input{songs/nehmt-abschied-brueder}
|
|
||||||
\input{songs/nicht-nur-nebenbei}
|
|
||||||
\input{songs/nichts-fuer-suesse-ziehharmonika}
|
|
||||||
\input{songs/noch-lange-sassen-wir}
|
|
||||||
\input{songs/nordwaerts}
|
|
||||||
\input{songs/nun-greift-in-die-saiten}
|
|
||||||
\input{songs/nun-lustig-lustig}
|
|
||||||
\input{songs/oh-fischer}
|
|
||||||
\input{songs/oh-bootsmann}
|
|
||||||
\input{songs/originale-3-strophe}
|
|
||||||
\input{songs/panama}
|
|
||||||
\input{songs/papst-sultan}
|
|
||||||
\input{songs/platoff}
|
|
||||||
\input{songs/refrain-2x}
|
|
||||||
\input{songs/rote-ritterscharen}
|
|
||||||
\input{songs/roter-mond}
|
|
||||||
\input{songs/roter-wein-im-becher}
|
|
||||||
\input{songs/santiano}
|
|
||||||
\input{songs/santiano-2}
|
|
||||||
\input{songs/scarborough-fair}
|
|
||||||
\input{songs/schilf-bleicht}
|
|
||||||
\input{songs/schlaf-mein-bub}
|
|
||||||
\input{songs/schliess-aug-und-ohr}
|
|
||||||
\input{songs/sei-der-abend}
|
|
||||||
\input{songs/she-hangs-her-head-sad-lisa}
|
|
||||||
\input{songs/siehst-du-die-feuer}
|
|
||||||
\input{songs/singt-freunde-lasst-die-klampfen}
|
|
||||||
\input{songs/so-trolln-wir-uns}
|
|
||||||
\input{songs/sonnenschein-und-wilde-feste}
|
|
||||||
\input{songs/star-of-county-down}
|
|
||||||
\input{songs/stiebt-vom-kasbek}
|
|
||||||
\input{songs/stille-tage}
|
|
||||||
\input{songs/strassen-auf-und-strassen-ab}
|
|
||||||
\input{songs/sturm-bricht-los}
|
|
||||||
\input{songs/sturm-und-drang}
|
|
||||||
\input{songs/tief-im-busch}
|
|
||||||
\input{songs/trinklied-vor-dem-abgang}
|
|
||||||
\input{songs/trommeln-und-pfeifen}
|
|
||||||
\input{songs/turm-um-uns}
|
|
||||||
\input{songs/ty-morjak-deutscher-text}
|
|
||||||
\input{songs/und-am-abend}
|
|
||||||
\input{songs/und-der-herbst}
|
|
||||||
\input{songs/und-die-morgenfruehe}
|
|
||||||
\input{songs/unglueck-vor-mir}
|
|
||||||
\input{songs/unser-stammesbus}
|
|
||||||
\input{songs/unter-den-toren}
|
|
||||||
\input{songs/vagabundenlied}
|
|
||||||
\input{songs/verliebt-in-du-intro-c-g-a-a-2x}
|
|
||||||
\input{songs/viva-la-feria}
|
|
||||||
\input{songs/vom-barette}
|
|
||||||
\input{songs/von-allen-blauen-huegeln}
|
|
||||||
\input{songs/von-der-festung-droehnt}
|
|
||||||
\input{songs/von-ueberall}
|
|
||||||
\input{songs/wach-nun-auf}
|
|
||||||
\input{songs/was-gehn-euch-meine}
|
|
||||||
\input{songs/was-helfen-mir-tausend-dukaten}
|
|
||||||
\input{songs/was-kann-ich-denn-dafuer}
|
|
||||||
\input{songs/was-keiner-wagt}
|
|
||||||
\input{songs/was-sollen-wir-trinken}
|
|
||||||
\input{songs/weisser-sand}
|
|
||||||
\input{songs/welle-wogte}
|
|
||||||
\input{songs/wem-gott-will-rechte-gunst-erweisen}
|
|
||||||
\input{songs/wenn-alle-bruennlein}
|
|
||||||
\input{songs/wenn-der-abend-naht}
|
|
||||||
\input{songs/wenn-der-fruehling-kommt}
|
|
||||||
\input{songs/wenn-die-bunten-fahnen}
|
|
||||||
\input{songs/wenn-die-zeit}
|
|
||||||
\input{songs/wenn-hell-die-goldne-sonne}
|
|
||||||
\input{songs/wenn-ich-des-morgens}
|
|
||||||
\input{songs/weronika-mit-w}
|
|
||||||
\input{songs/what-shall-we-do-drunken-sailor}
|
|
||||||
\input{songs/whats-right-ye-jacobites}
|
|
||||||
\input{songs/whiskey-in-the-jar}
|
|
||||||
\input{songs/wie-ein-fest-nach-langer-trauer}
|
|
||||||
\input{songs/wie-kommts-dass-du}
|
|
||||||
\input{songs/wie-schoen-blueht}
|
|
||||||
\input{songs/wild-rover}
|
|
||||||
\input{songs/wilde-gesellen}
|
|
||||||
\input{songs/wilde-reiter}
|
|
||||||
\input{songs/wildgaense}
|
|
||||||
\input{songs/wir-drei-wir-gehn-jetzt}
|
|
||||||
\input{songs/wir-fahren-uebers-weite-meer}
|
|
||||||
\input{songs/wir-haben-das-sehen}
|
|
||||||
\input{songs/wir-kamen-einst}
|
|
||||||
\input{songs/wir-lagen-vor-madagaskar}
|
|
||||||
\input{songs/wir-lieben-die-stuerme}
|
|
||||||
\input{songs/wir-rufen-zu-dir-michaelslied}
|
|
||||||
\input{songs/wir-sassen-in-johnnys-spelunke}
|
|
||||||
\input{songs/wir-sind-des-geyers}
|
|
||||||
\input{songs/wir-sind-durch-deutschland-gefahren}
|
|
||||||
\input{songs/wir-sind-eine-kleine}
|
|
||||||
\input{songs/wir-sind-kameraden}
|
|
||||||
\input{songs/wir-sind-wieder-da-st-goar-hymne}
|
|
||||||
\input{songs/wir-sitzen-im-rostigen-haifsch}
|
|
||||||
\input{songs/wir-sitzen-zu-pferde}
|
|
||||||
\input{songs/wir-wollen-zu-land-ausfahren}
|
|
||||||
\input{songs/wir-wolln-im-gruenen-wald}
|
|
||||||
\input{songs/wo-schorle-der-apfelschorlen-blues}
|
|
||||||
\input{songs/wohl-ueber-erde}
|
|
||||||
\input{songs/wohlauf-die-luft}
|
|
||||||
\input{songs/wollt-ihr-hoeren}
|
|
||||||
\input{songs/wos-nur-felsen}
|
|
||||||
\input{songs/zieh-meiner-strasse}
|
|
||||||
\input{songs/zogen-einst}
|
|
||||||
\input{songs/zogen-viele-strassen}
|
|
||||||
\input{songs/ueber-meiner-heimat}
|
|
||||||
16
app/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
plugins {
|
||||||
|
id("songbook-conventions")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":model"))
|
||||||
|
implementation(project(":parser"))
|
||||||
|
implementation(project(":layout"))
|
||||||
|
implementation(project(":renderer-pdf"))
|
||||||
|
|
||||||
|
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||||
|
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||||
|
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package de.pfadfinder.songbook.app
|
||||||
|
|
||||||
|
import de.pfadfinder.songbook.model.*
|
||||||
|
import de.pfadfinder.songbook.parser.*
|
||||||
|
import de.pfadfinder.songbook.layout.*
|
||||||
|
import de.pfadfinder.songbook.renderer.pdf.*
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
data class BuildResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val outputFile: File? = null,
|
||||||
|
val errors: List<ValidationError> = emptyList(),
|
||||||
|
val songCount: Int = 0,
|
||||||
|
val pageCount: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
class SongbookPipeline(private val projectDir: File) {
|
||||||
|
|
||||||
|
fun build(): BuildResult {
|
||||||
|
// 1. Parse config
|
||||||
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
|
if (!configFile.exists()) {
|
||||||
|
return BuildResult(false, errors = listOf(ValidationError(configFile.name, null, "songbook.yaml not found")))
|
||||||
|
}
|
||||||
|
logger.info { "Parsing config: ${configFile.absolutePath}" }
|
||||||
|
val config = ConfigParser.parse(configFile)
|
||||||
|
|
||||||
|
// Validate config
|
||||||
|
val configErrors = Validator.validateConfig(config)
|
||||||
|
if (configErrors.isNotEmpty()) {
|
||||||
|
return BuildResult(false, errors = configErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse songs
|
||||||
|
val songsDir = File(projectDir, config.songs.directory)
|
||||||
|
if (!songsDir.exists() || !songsDir.isDirectory) {
|
||||||
|
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "Songs directory not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
if (songFiles.isEmpty()) {
|
||||||
|
return BuildResult(false, errors = listOf(ValidationError(config.songs.directory, null, "No song files found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Found ${songFiles.size} song files" }
|
||||||
|
|
||||||
|
val songs = mutableListOf<Song>()
|
||||||
|
val allErrors = mutableListOf<ValidationError>()
|
||||||
|
|
||||||
|
for (file in songFiles) {
|
||||||
|
try {
|
||||||
|
val song = ChordProParser.parseFile(file)
|
||||||
|
val songErrors = Validator.validateSong(song, file.name)
|
||||||
|
if (songErrors.isNotEmpty()) {
|
||||||
|
allErrors.addAll(songErrors)
|
||||||
|
} else {
|
||||||
|
songs.add(song)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.isNotEmpty()) {
|
||||||
|
return BuildResult(false, errors = allErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort songs
|
||||||
|
val sortedSongs = when (config.songs.order) {
|
||||||
|
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||||
|
else -> songs // manual order = file order
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||||
|
|
||||||
|
// 3. Measure songs
|
||||||
|
val fontMetrics = PdfFontMetrics()
|
||||||
|
val measurementEngine = MeasurementEngine(fontMetrics, config)
|
||||||
|
val measuredSongs = sortedSongs.map { measurementEngine.measure(it) }
|
||||||
|
|
||||||
|
// 4. Generate TOC and paginate
|
||||||
|
val tocGenerator = TocGenerator(config)
|
||||||
|
val tocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||||
|
|
||||||
|
val paginationEngine = PaginationEngine(config)
|
||||||
|
val pages = paginationEngine.paginate(measuredSongs, tocPages)
|
||||||
|
|
||||||
|
val tocEntries = tocGenerator.generate(pages, tocPages)
|
||||||
|
|
||||||
|
val layoutResult = LayoutResult(
|
||||||
|
tocPages = tocPages,
|
||||||
|
pages = pages,
|
||||||
|
tocEntries = tocEntries
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info { "Layout: ${tocPages} TOC pages, ${pages.size} content pages" }
|
||||||
|
|
||||||
|
// 5. Render PDF
|
||||||
|
val outputDir = File(projectDir, config.output.directory)
|
||||||
|
outputDir.mkdirs()
|
||||||
|
val outputFile = File(outputDir, config.output.filename)
|
||||||
|
|
||||||
|
logger.info { "Rendering PDF: ${outputFile.absolutePath}" }
|
||||||
|
|
||||||
|
val renderer = PdfBookRenderer()
|
||||||
|
FileOutputStream(outputFile).use { fos ->
|
||||||
|
renderer.render(layoutResult, config, fos)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Build complete: ${sortedSongs.size} songs, ${pages.size + tocPages} pages" }
|
||||||
|
|
||||||
|
return BuildResult(
|
||||||
|
success = true,
|
||||||
|
outputFile = outputFile,
|
||||||
|
songCount = sortedSongs.size,
|
||||||
|
pageCount = pages.size + tocPages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate(): List<ValidationError> {
|
||||||
|
val configFile = File(projectDir, "songbook.yaml")
|
||||||
|
if (!configFile.exists()) {
|
||||||
|
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = ConfigParser.parse(configFile)
|
||||||
|
val errors = mutableListOf<ValidationError>()
|
||||||
|
errors.addAll(Validator.validateConfig(config))
|
||||||
|
|
||||||
|
val songsDir = File(projectDir, config.songs.directory)
|
||||||
|
if (!songsDir.exists()) {
|
||||||
|
errors.add(ValidationError(config.songs.directory, null, "Songs directory not found"))
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
for (file in songFiles) {
|
||||||
|
try {
|
||||||
|
val song = ChordProParser.parseFile(file)
|
||||||
|
errors.addAll(Validator.validateSong(song, file.name))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
errors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
package de.pfadfinder.songbook.app
|
||||||
|
|
||||||
|
import io.kotest.matchers.booleans.shouldBeFalse
|
||||||
|
import io.kotest.matchers.booleans.shouldBeTrue
|
||||||
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
|
import io.kotest.matchers.collections.shouldNotBeEmpty
|
||||||
|
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class SongbookPipelineTest {
|
||||||
|
|
||||||
|
private fun createTempProject(): File {
|
||||||
|
val dir = kotlin.io.path.createTempDirectory("songbook-test").toFile()
|
||||||
|
dir.deleteOnExit()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeConfig(projectDir: File, config: String = defaultConfig()) {
|
||||||
|
File(projectDir, "songbook.yaml").writeText(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultConfig(
|
||||||
|
songsDir: String = "./songs",
|
||||||
|
outputDir: String = "./output",
|
||||||
|
outputFilename: String = "liederbuch.pdf",
|
||||||
|
order: String = "alphabetical"
|
||||||
|
): String = """
|
||||||
|
book:
|
||||||
|
title: "Test Liederbuch"
|
||||||
|
format: "A5"
|
||||||
|
songs:
|
||||||
|
directory: "$songsDir"
|
||||||
|
order: "$order"
|
||||||
|
fonts:
|
||||||
|
lyrics:
|
||||||
|
family: "Helvetica"
|
||||||
|
size: 10
|
||||||
|
chords:
|
||||||
|
family: "Helvetica"
|
||||||
|
size: 9
|
||||||
|
title:
|
||||||
|
family: "Helvetica"
|
||||||
|
size: 14
|
||||||
|
metadata:
|
||||||
|
family: "Helvetica"
|
||||||
|
size: 8
|
||||||
|
toc:
|
||||||
|
family: "Helvetica"
|
||||||
|
size: 9
|
||||||
|
layout:
|
||||||
|
margins:
|
||||||
|
top: 15
|
||||||
|
bottom: 15
|
||||||
|
inner: 20
|
||||||
|
outer: 12
|
||||||
|
images:
|
||||||
|
directory: "./images"
|
||||||
|
output:
|
||||||
|
directory: "$outputDir"
|
||||||
|
filename: "$outputFilename"
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun writeSongFile(songsDir: File, filename: String, content: String) {
|
||||||
|
songsDir.mkdirs()
|
||||||
|
File(songsDir, filename).writeText(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleSong(title: String = "Test Song"): String = """
|
||||||
|
{title: $title}
|
||||||
|
{start_of_verse}
|
||||||
|
[Am]Hello [C]world
|
||||||
|
This is a test
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// --- build() tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error when songbook yaml is missing`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors shouldHaveSize 1
|
||||||
|
result.errors[0].message shouldContain "songbook.yaml not found"
|
||||||
|
result.outputFile.shouldBeNull()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error when songs directory does not exist`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors shouldHaveSize 1
|
||||||
|
result.errors[0].message shouldContain "Songs directory not found"
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error when songs directory is empty`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
File(projectDir, "songs").mkdirs()
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors shouldHaveSize 1
|
||||||
|
result.errors[0].message shouldContain "No song files found"
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error for invalid config with zero margins`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
val config = """
|
||||||
|
book:
|
||||||
|
title: "Test"
|
||||||
|
layout:
|
||||||
|
margins:
|
||||||
|
top: 0
|
||||||
|
bottom: 15
|
||||||
|
inner: 20
|
||||||
|
outer: 12
|
||||||
|
""".trimIndent()
|
||||||
|
writeConfig(projectDir, config)
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors.shouldNotBeEmpty()
|
||||||
|
result.errors.any { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error for song with missing title`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "bad_song.chopro", """
|
||||||
|
{start_of_verse}
|
||||||
|
[Am]Hello world
|
||||||
|
{end_of_verse}
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors.shouldNotBeEmpty()
|
||||||
|
result.errors.any { it.message.contains("title", ignoreCase = true) }.shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build returns error for song with no sections`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "empty_song.chopro", "{title: Empty Song}")
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.errors.shouldNotBeEmpty()
|
||||||
|
result.errors.any { it.message.contains("section", ignoreCase = true) }.shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build succeeds with valid project`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong("Alpha Song"))
|
||||||
|
writeSongFile(songsDir, "song2.chopro", sampleSong("Beta Song"))
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.errors.shouldBeEmpty()
|
||||||
|
result.outputFile.shouldNotBeNull()
|
||||||
|
result.outputFile!!.exists().shouldBeTrue()
|
||||||
|
result.songCount shouldBe 2
|
||||||
|
result.pageCount shouldBeGreaterThan 0
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build creates output directory if it does not exist`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(outputDir = "./out/build"))
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
File(projectDir, "out/build").exists().shouldBeTrue()
|
||||||
|
result.outputFile!!.exists().shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with alphabetical order sorts songs by title`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(order = "alphabetical"))
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "z_first.chopro", sampleSong("Zebra Song"))
|
||||||
|
writeSongFile(songsDir, "a_second.chopro", sampleSong("Alpha Song"))
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.songCount shouldBe 2
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build with manual order preserves file order`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(order = "manual"))
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "02_second.chopro", sampleSong("Second Song"))
|
||||||
|
writeSongFile(songsDir, "01_first.chopro", sampleSong("First Song"))
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.songCount shouldBe 2
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build recognizes cho extension`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.cho", sampleSong("Cho Song"))
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.songCount shouldBe 1
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build recognizes crd extension`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.crd", sampleSong("Crd Song"))
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.songCount shouldBe 1
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build ignores non-song files in songs directory`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong("Real Song"))
|
||||||
|
writeSongFile(songsDir, "readme.txt", "Not a song")
|
||||||
|
writeSongFile(songsDir, "notes.md", "# Notes")
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.songCount shouldBe 1
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build output file has correct name`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(outputFilename = "my-book.pdf"))
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.outputFile!!.name shouldBe "my-book.pdf"
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build pageCount includes toc pages`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
// At least 1 content page + TOC pages (minimum 2 for even count)
|
||||||
|
result.pageCount shouldBeGreaterThan 1
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validate() tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate returns error when songbook yaml is missing`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors shouldHaveSize 1
|
||||||
|
errors[0].message shouldContain "songbook.yaml not found"
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate returns error when songs directory does not exist`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir, defaultConfig(songsDir = "./nonexistent"))
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors.shouldNotBeEmpty()
|
||||||
|
errors.any { it.message.contains("Songs directory not found") }.shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate returns empty list for valid project`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "song1.chopro", sampleSong())
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors.shouldBeEmpty()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate reports config errors`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
val config = """
|
||||||
|
layout:
|
||||||
|
margins:
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
inner: 0
|
||||||
|
outer: 0
|
||||||
|
""".trimIndent()
|
||||||
|
writeConfig(projectDir, config)
|
||||||
|
// Still need songs dir to exist for full validate
|
||||||
|
File(projectDir, "./songs").mkdirs()
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors shouldHaveSize 4
|
||||||
|
errors.all { it.message.contains("margin", ignoreCase = true) }.shouldBeTrue()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate reports song validation errors`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "bad_song.chopro", "{title: }")
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors.shouldNotBeEmpty()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate reports errors for multiple invalid songs`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
val songsDir = File(projectDir, "songs")
|
||||||
|
writeSongFile(songsDir, "bad1.chopro", "{title: Good Title}") // no sections
|
||||||
|
writeSongFile(songsDir, "bad2.chopro", "{title: Another Title}") // no sections
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
errors.shouldNotBeEmpty()
|
||||||
|
errors.size shouldBeGreaterThan 1
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validate with empty songs directory returns no song errors`() {
|
||||||
|
val projectDir = createTempProject()
|
||||||
|
try {
|
||||||
|
writeConfig(projectDir)
|
||||||
|
File(projectDir, "songs").mkdirs()
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(projectDir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
// No errors because there are no song files to validate
|
||||||
|
errors.shouldBeEmpty()
|
||||||
|
} finally {
|
||||||
|
projectDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BuildResult data class tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `BuildResult defaults are correct`() {
|
||||||
|
val result = BuildResult(success = false)
|
||||||
|
|
||||||
|
result.success.shouldBeFalse()
|
||||||
|
result.outputFile.shouldBeNull()
|
||||||
|
result.errors.shouldBeEmpty()
|
||||||
|
result.songCount shouldBe 0
|
||||||
|
result.pageCount shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `BuildResult with all fields set`() {
|
||||||
|
val file = File("/tmp/test.pdf")
|
||||||
|
val errors = listOf(de.pfadfinder.songbook.parser.ValidationError("test", 1, "error"))
|
||||||
|
val result = BuildResult(
|
||||||
|
success = true,
|
||||||
|
outputFile = file,
|
||||||
|
errors = errors,
|
||||||
|
songCount = 5,
|
||||||
|
pageCount = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
result.success.shouldBeTrue()
|
||||||
|
result.outputFile shouldBe file
|
||||||
|
result.errors shouldHaveSize 1
|
||||||
|
result.songCount shouldBe 5
|
||||||
|
result.pageCount shouldBe 10
|
||||||
|
}
|
||||||
|
}
|
||||||
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("org.jetbrains.compose") version "1.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
|
||||||
|
}
|
||||||
12
buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
||||||
|
}
|
||||||
18
buildSrc/src/main/kotlin/songbook-conventions.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
17
cli/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
plugins {
|
||||||
|
id("songbook-conventions")
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("de.pfadfinder.songbook.cli.MainKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":app"))
|
||||||
|
implementation(project(":model"))
|
||||||
|
implementation(project(":parser"))
|
||||||
|
implementation("com.github.ajalt.clikt:clikt:5.0.3")
|
||||||
|
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||||
|
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.pfadfinder.songbook.cli
|
||||||
|
|
||||||
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
|
import com.github.ajalt.clikt.core.Context
|
||||||
|
import com.github.ajalt.clikt.core.ProgramResult
|
||||||
|
import com.github.ajalt.clikt.parameters.options.default
|
||||||
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
|
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class BuildCommand : CliktCommand(name = "build") {
|
||||||
|
override fun help(context: Context) = "Build the songbook PDF"
|
||||||
|
|
||||||
|
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
val dir = File(projectDir).absoluteFile
|
||||||
|
echo("Building songbook from: ${dir.path}")
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(dir)
|
||||||
|
val result = pipeline.build()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
echo("Build successful!")
|
||||||
|
echo(" Songs: ${result.songCount}")
|
||||||
|
echo(" Pages: ${result.pageCount}")
|
||||||
|
echo(" Output: ${result.outputFile?.absolutePath}")
|
||||||
|
} else {
|
||||||
|
echo("Build failed with ${result.errors.size} error(s):", err = true)
|
||||||
|
for (error in result.errors) {
|
||||||
|
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
|
||||||
|
echo(" [$location] ${error.message}", err = true)
|
||||||
|
}
|
||||||
|
throw ProgramResult(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
cli/src/main/kotlin/de/pfadfinder/songbook/cli/Main.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package de.pfadfinder.songbook.cli
|
||||||
|
|
||||||
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
|
import com.github.ajalt.clikt.core.main
|
||||||
|
import com.github.ajalt.clikt.core.subcommands
|
||||||
|
|
||||||
|
class SongbookCli : CliktCommand(name = "songbook") {
|
||||||
|
override fun run() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
SongbookCli()
|
||||||
|
.subcommands(BuildCommand(), ValidateCommand())
|
||||||
|
.main(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.pfadfinder.songbook.cli
|
||||||
|
|
||||||
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
|
import com.github.ajalt.clikt.core.Context
|
||||||
|
import com.github.ajalt.clikt.core.ProgramResult
|
||||||
|
import com.github.ajalt.clikt.parameters.options.default
|
||||||
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
|
import de.pfadfinder.songbook.app.SongbookPipeline
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class ValidateCommand : CliktCommand(name = "validate") {
|
||||||
|
override fun help(context: Context) = "Validate all song files"
|
||||||
|
|
||||||
|
private val projectDir by option("-d", "--dir", help = "Project directory").default(".")
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
val dir = File(projectDir).absoluteFile
|
||||||
|
echo("Validating songbook in: ${dir.path}")
|
||||||
|
|
||||||
|
val pipeline = SongbookPipeline(dir)
|
||||||
|
val errors = pipeline.validate()
|
||||||
|
|
||||||
|
if (errors.isEmpty()) {
|
||||||
|
echo("All songs are valid!")
|
||||||
|
} else {
|
||||||
|
echo("Found ${errors.size} error(s):", err = true)
|
||||||
|
for (error in errors) {
|
||||||
|
val location = listOfNotNull(error.file, error.line?.toString()).joinToString(":")
|
||||||
|
echo(" [$location] ${error.message}", err = true)
|
||||||
|
}
|
||||||
|
throw ProgramResult(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
||||||
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
@@ -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)
|
||||||
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB |