Compare commits
19 Commits
13d1d4525c
...
latex-rewr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a0cd7aa3 | ||
|
|
2d4e1554c7 | ||
|
|
63fe1effdb | ||
|
|
bb2f829e2f | ||
|
|
07aa76c3f6 | ||
|
|
0e8660cd41 | ||
|
|
c202f1a792 | ||
|
|
e771264244 | ||
|
|
44ea072716 | ||
|
|
21c369da36 | ||
|
|
7b99778f67 | ||
|
|
d875fd225b | ||
|
|
44d2fb9b5e | ||
|
|
93f451eef9 | ||
|
|
ab00b710b1 | ||
|
|
5a63067b93 | ||
|
|
cae0c52b67 | ||
|
|
692be693e9 | ||
|
|
4024d0e421 |
35
.gitignore
vendored
@@ -1,22 +1,29 @@
|
|||||||
# Gradle
|
# LaTeX build artifacts
|
||||||
.gradle/
|
*.aux
|
||||||
build/
|
*.log
|
||||||
buildSrc/build/
|
*.out
|
||||||
|
*.toc
|
||||||
|
*.fls
|
||||||
|
*.fdb_latexmk
|
||||||
|
*.synctex.gz
|
||||||
|
*.synctex(busy)
|
||||||
|
*.sxd
|
||||||
|
*.sxc
|
||||||
|
|
||||||
# IDE
|
# Output directory
|
||||||
.idea/
|
output/
|
||||||
*.iml
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# OS
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Output
|
# Editor files
|
||||||
output/
|
.idea/
|
||||||
|
*.iml
|
||||||
# Kotlin
|
.vscode/
|
||||||
*.class
|
*~
|
||||||
|
*.swp
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
118
CLAUDE.md
@@ -2,79 +2,83 @@
|
|||||||
|
|
||||||
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 & Test Commands
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build everything
|
# Build the songbook PDF (two-pass for TOC)
|
||||||
gradle build
|
make
|
||||||
|
|
||||||
# Run all tests
|
# Remove auxiliary files
|
||||||
gradle test
|
make clean
|
||||||
|
|
||||||
# Run tests for a specific module
|
# Remove everything including PDF
|
||||||
gradle :parser:test
|
make distclean
|
||||||
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 Java 21 (configured in `gradle.properties`). Kotlin 2.1.10, Gradle 9.3.1.
|
Requires LuaLaTeX (TeX Live) and the `leadsheets` package.
|
||||||
|
|
||||||
## Architecture
|
## Project Structure
|
||||||
|
|
||||||
**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:**
|
|
||||||
```
|
```
|
||||||
model ← parser
|
songbook.tex # Main document (title page, TOC, song inputs)
|
||||||
model ← layout
|
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
||||||
model ← renderer-pdf
|
songs/ # One .tex file per song
|
||||||
parser, layout, renderer-pdf ← app
|
fonts/ # Font files (UnifrakturMaguntia for titles)
|
||||||
app ← cli (Clikt)
|
images/ # Filler images (empty for now)
|
||||||
app, parser ← gui (Compose Desktop)
|
Makefile # Build rules (lualatex, two passes)
|
||||||
|
output/ # Generated PDF (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
`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.
|
## How It Works
|
||||||
|
|
||||||
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this.
|
Pure LaTeX songbook using the `leadsheets` package with LuaLaTeX. The style matches the Carmina Leonis songbook format:
|
||||||
|
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
||||||
## Key Types
|
- Chords above lyrics in regular weight, black
|
||||||
|
- No verse labels (verses separated by blank lines)
|
||||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment
|
- Metadata (Worte/Weise) at bottom of each song page
|
||||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`
|
- Reference book cross-references (MO, PfLB) in footer
|
||||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
- Each song starts on a new page
|
||||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
- A5 twoside format with page numbers at bottom-outer
|
||||||
|
|
||||||
## Song Format
|
## Song Format
|
||||||
|
|
||||||
ChordPro-compatible `.chopro` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
Each song uses the `leadsheets` `song` environment:
|
||||||
|
|
||||||
## Test Patterns
|
```latex
|
||||||
|
\begin{song}{
|
||||||
|
title = Song Title,
|
||||||
|
lyrics = Lyricist,
|
||||||
|
composer = Composer,
|
||||||
|
key = G,
|
||||||
|
mundorgel = 42,
|
||||||
|
pfadfinderliederbuch = 118,
|
||||||
|
note = {Optional note text.},
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
\begin{verse}
|
||||||
|
\chord{G}Lyrics with \chord{D}chords above. \\
|
||||||
|
Next \chord{C}line here.
|
||||||
|
\end{verse}
|
||||||
|
|
||||||
## Package
|
\begin{verse}
|
||||||
|
Second verse without chords (or with).
|
||||||
|
\end{verse}
|
||||||
|
|
||||||
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
\end{song}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
% 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}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("org.jetbrains.compose") version "1.7.3" apply false
|
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
plugins {
|
|
||||||
`kotlin-dsl`
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
fonts/UnifrakturMaguntia-Book.ttf
Normal file
@@ -1 +0,0 @@
|
|||||||
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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)
|
|
||||||
BIN
images/img-000.jpg
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
images/img-001.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
images/img-002.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
images/img-003.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
images/img-004.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
images/img-005.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/img-006.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
images/img-007.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/img-008.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
images/img-009.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
images/img-010.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/img-011.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/img-013.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
images/img-014.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
images/img-015.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/img-016.jpg
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
images/img-017.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
images/img-018.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/img-019.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
images/img-020.jpg
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
images/img-021.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/img-022.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
images/img-024.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
images/img-025.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
images/img-026.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
images/img-027.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
images/img-028.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
images/img-029.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
images/img-030.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
images/img-031.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
images/img-032.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
images/img-033.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
images/img-034.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
images/img-035.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
images/img-036.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/img-038.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/img-039.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
images/img-040.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
images/img-041.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
images/img-042.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
images/img-043.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
images/img-044.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
images/img-045.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
images/img-046.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/img-047.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
images/img-048.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
images/img-049.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/img-050.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
images/img-051.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
images/img-053.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
images/img-054.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
images/img-055.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
images/img-056.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
images/img-057.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
images/img-059.jpg
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
images/img-060.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
images/img-061.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/img-062.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
images/img-063.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
images/img-064.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
images/img-065.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/img-066.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
images/img-067.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
images/img-068.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
images/img-069.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
images/img-070.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/img-071.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
images/img-072.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
images/img-073.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
images/img-074.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
images/img-075.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/img-076.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
images/img-077.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
images/img-078.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
images/img-079.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/img-080.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
images/img-081.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
images/img-082.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
images/img-083.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
images/img-084.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
images/img-085.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
images/img-086.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |