Compare commits
18 Commits
v0.4.0-lat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d346e899d | ||
| 9056dbd9cd | |||
| 543fe66a44 | |||
| 032387c02d | |||
| a251fac053 | |||
| 077b3c027e | |||
| d733e83cb1 | |||
| 0f038a68d8 | |||
| a69d14033d | |||
| 0fb2771279 | |||
|
|
5378bdbc24 | ||
|
|
ab91ad2db6 | ||
|
|
b339c10ca0 | ||
|
|
8dca7d7131 | ||
|
|
8c92c7d78b | ||
|
|
0139327034 | ||
|
|
ba035159f7 | ||
|
|
8e4728c55a |
37
.gitignore
vendored
@@ -1,29 +1,22 @@
|
||||
# LaTeX build artifacts
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
*.synctex(busy)
|
||||
*.sxd
|
||||
*.sxc
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
buildSrc/build/
|
||||
|
||||
# Output directory
|
||||
output/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Output
|
||||
output/
|
||||
|
||||
# Kotlin
|
||||
*.class
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
__pycache__/
|
||||
|
||||
109
.plans/issue-17-page-overflow.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Issue #17: Fix page overflow — bounds checking and content splitting
|
||||
|
||||
## Summary
|
||||
|
||||
The PDF renderer (`PdfBookRenderer.renderSongPage()`) currently renders all song sections on page 0 and leaves page 1 blank for 2-page songs. There is no bounds checking — the `y` coordinate can go below the bottom margin, causing content to render outside the visible page area. This plan adds proper `y`-position tracking against a minimum `yMin` boundary, splits content across pages at section boundaries when a song exceeds one page, and reserves space for bottom metadata/references on the last page.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. The renderer tracks `y` against the bottom margin during song page rendering
|
||||
2. For 2-page songs, content splits across pages when it exceeds page 0's available space — remaining sections continue on page 1
|
||||
3. Content that would be rendered below the bottom margin (minus reserved footer space) is moved to the next page
|
||||
4. If metadata is "bottom" position, sufficient space is reserved at the bottom of the last page
|
||||
5. No text or images are rendered outside the printable page area
|
||||
6. Existing tests continue to pass
|
||||
7. New tests verify content splitting for songs exceeding one page
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add a section height calculation helper in PdfBookRenderer
|
||||
|
||||
Add a private method `calculateSectionHeight()` that computes how many PDF points a given `SongSection` will consume when rendered. This mirrors the measurement engine logic but uses the actual PDF `BaseFont` widths (not stubs). This is needed to decide whether a section fits on the current page.
|
||||
|
||||
The method signature:
|
||||
```kotlin
|
||||
private fun calculateSectionHeight(
|
||||
section: SongSection,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float
|
||||
```
|
||||
|
||||
### Step 2: Add footer space reservation calculation
|
||||
|
||||
Add a private method `calculateFooterReservation()` that computes how much vertical space must be reserved at the bottom of the **last** page of a song for:
|
||||
- Bottom-position metadata (if `metadataPosition == "bottom"`)
|
||||
- Notes
|
||||
- Reference book footer
|
||||
|
||||
```kotlin
|
||||
private fun calculateFooterReservation(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float
|
||||
): Float
|
||||
```
|
||||
|
||||
### Step 3: Refactor renderSongPage() to split content across pages
|
||||
|
||||
The key change: Instead of `val sections = if (pageIndex == 0) song.sections else emptyList()`, determine which sections belong on each page by:
|
||||
|
||||
1. Calculate `yMin` = bottom margin in points (plus footer reservation for the last page)
|
||||
2. For `pageIndex == 0`: Render sections in order. Before rendering each section, check if the section's height fits above `yMin`. If not, stop — remaining sections go to page 1.
|
||||
3. For `pageIndex == 1`: Render the sections that didn't fit on page 0. The split point is stored via a `splitIndex` that is computed during page 0 rendering.
|
||||
|
||||
**Approach:** Since `renderSongPage()` is called separately for page 0 and page 1, we need a way to know the split point on both calls. The cleanest approach is to compute the split index as a function:
|
||||
|
||||
```kotlin
|
||||
private fun computeSplitIndex(
|
||||
song: Song,
|
||||
fontMetrics: PdfFontMetrics,
|
||||
config: BookConfig,
|
||||
contentWidth: Float,
|
||||
availableHeight: Float // total space on page 0 (contentTop - yMin)
|
||||
): Int // index of first section that goes to page 1
|
||||
```
|
||||
|
||||
This method calculates the cumulative height of header + sections. When the cumulative height exceeds `availableHeight`, it returns the section index. If all sections fit, it returns `song.sections.size`.
|
||||
|
||||
### Step 4: Update renderSongPage() to use bounds checking during rendering
|
||||
|
||||
Even after determining the split, the actual rendering loop should still check `y >= yMin` as a safety net. If a section that was estimated to fit actually overflows (due to measurement inaccuracy), clamp rendering — do not render below `yMin`.
|
||||
|
||||
### Step 5: Update footer rendering for multi-page songs
|
||||
|
||||
Currently `isLastPage` is hardcoded to `pageIndex == 0`. Change it to correctly identify the last page:
|
||||
- For 1-page songs: `pageIndex == 0` is the last page
|
||||
- For 2-page songs: `pageIndex == 1` is the last page
|
||||
|
||||
The song's `pageCount` isn't directly available in the renderer, but we can determine it: if `pageIndex == 1`, it's always the last page. If `pageIndex == 0`, it's the last page only if the song fits on one page (i.e., `computeSplitIndex == song.sections.size`).
|
||||
|
||||
A simpler approach: pass the total page count as a parameter, or compute whether the song needs 2 pages inside `renderSongPage()`.
|
||||
|
||||
**Decision:** Add a `totalPages: Int` parameter to `renderSongPage()`. The caller already knows this from the `PageContent.SongPage` list (consecutive song pages with pageIndex 0 and 1 for the same song).
|
||||
|
||||
Actually, the simplest approach: The renderer sees `PageContent.SongPage(song, 0)` and `PageContent.SongPage(song, 1)` in the page list. We can pre-scan the pages list to know if a song has 2 pages. But even simpler: we can compute `computeSplitIndex` to know whether the song needs a second page. If `splitIndex < song.sections.size`, the song has 2 pages.
|
||||
|
||||
### Step 6: Move notes and bottom-metadata to the last page
|
||||
|
||||
Currently notes and bottom metadata only render on `pageIndex == 0`. Change this to render on the last page (which might be page 1 for 2-page songs). The logic:
|
||||
- Compute `isLastPage` based on split index
|
||||
- Render notes, bottom metadata, and reference footer only on the last page
|
||||
|
||||
### Step 7: Write tests
|
||||
|
||||
Add tests in `PdfBookRendererTest`:
|
||||
|
||||
1. `render handles two-page song with content split across pages` — Create a song with many sections that exceed one page, render with pageIndex 0 and 1, verify PDF is valid.
|
||||
|
||||
2. `render does not overflow below bottom margin` — Create a very long song, verify rendering completes without error.
|
||||
|
||||
3. `render places metadata at bottom of last page for two-page songs` — Use `metadataPosition = "bottom"`, create a 2-page song, verify PDF is valid.
|
||||
|
||||
4. `render handles notes on last page of two-page song` — Song with notes that spans 2 pages, verify rendering.
|
||||
|
||||
### Step 8: Verify existing tests pass
|
||||
|
||||
Run `gradle :renderer-pdf:test` and `gradle :app:test` to ensure no regressions.
|
||||
44
.plans/issue-18-page-preview.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Issue #18: Add page-by-page preview in the GUI after building
|
||||
|
||||
## Summary
|
||||
|
||||
Add a PDF preview panel to the GUI that appears after a successful build. It renders PDF pages as images using Apache PDFBox and displays them with previous/next navigation and a page counter. The preview loads pages lazily for performance and updates automatically on new builds.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. After a successful build, a preview panel appears showing generated pages
|
||||
2. Users can navigate between pages (previous/next buttons)
|
||||
3. Current page number and total count displayed (e.g., "Seite 3 / 42")
|
||||
4. Preview renders actual PDF pages as images (PDF-to-image via PDFBox)
|
||||
5. Preview panel can be closed/hidden to return to normal view
|
||||
6. Preview updates automatically when a new build completes
|
||||
7. GUI remains responsive while preview is loading (async rendering)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add PDFBox dependency to gui module
|
||||
|
||||
Add `org.apache.pdfbox:pdfbox:3.0.4` to gui/build.gradle.kts.
|
||||
|
||||
### Step 2: Create PdfPreviewState class
|
||||
|
||||
A state holder for the preview: current page index, total pages, rendered page images (cached), loading state. Pages are rendered lazily — only the current page is rendered at a time.
|
||||
|
||||
### Step 3: Create PdfPreviewPanel composable
|
||||
|
||||
A Compose panel with:
|
||||
- An Image composable showing the current page
|
||||
- Navigation row: "< Prev" button | "Seite X / Y" label | "Next >" button
|
||||
- A close/hide button
|
||||
- Loading indicator while page is rendering
|
||||
|
||||
### Step 4: Integrate preview into App composable
|
||||
|
||||
After a successful build:
|
||||
- Show a "Vorschau" button in the action buttons row
|
||||
- When clicked, show the preview panel (replacing or overlaying the song list area)
|
||||
- When a new build succeeds, update the preview automatically
|
||||
|
||||
### Step 5: Lazy page rendering
|
||||
|
||||
Render pages on demand using coroutines on Dispatchers.IO to keep the UI responsive.
|
||||
41
.plans/issue-19-drag-and-drop.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Issue #19: Add drag-and-drop song reordering in the GUI
|
||||
|
||||
## Summary
|
||||
|
||||
Add drag-and-drop reordering of songs in the GUI song list. When `songs.order` is "manual", users can drag songs to rearrange them. The custom order is passed to the pipeline at build time. When order is "alphabetical", drag-and-drop is disabled with a hint.
|
||||
|
||||
## AC Verification Checklist
|
||||
|
||||
1. Songs can be reordered via drag-and-drop
|
||||
2. Reordered list is used when building (overrides config order)
|
||||
3. Visual indicator shows drop target (highlight)
|
||||
4. Order can be reset via a button
|
||||
5. Reordering only enabled when songs.order is "manual"
|
||||
6. When alphabetical, list shows alphabetical order and drag is disabled (with hint)
|
||||
7. GUI remains responsive during drag operations
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add customSongOrder parameter to SongbookPipeline.build()
|
||||
|
||||
Add an optional `customSongOrder: List<String>? = null` parameter. When provided, use this ordered list of file names to sort the parsed songs instead of the config-based sort.
|
||||
|
||||
### Step 2: Create ReorderableSongList composable
|
||||
|
||||
Build a song list that supports drag-and-drop reordering:
|
||||
- Use `detectDragGesturesAfterLongPress` on each item to detect drag start
|
||||
- Track the dragged item index and current hover position
|
||||
- Show a visual indicator (highlighted background) at the drop target
|
||||
- On drop, reorder the list
|
||||
|
||||
### Step 3: Integrate into App.kt
|
||||
|
||||
- Track `songsOrder` config value ("alphabetical" or "manual")
|
||||
- Track `originalSongs` list (from file loading) to support reset
|
||||
- When manual: enable drag-and-drop, show reset button
|
||||
- When alphabetical: disable drag-and-drop, show hint
|
||||
- Pass custom order (file names) to pipeline on build
|
||||
|
||||
### Step 4: Add reset button
|
||||
|
||||
"Reihenfolge zurücksetzen" button restores `songs` to `originalSongs`.
|
||||
138
CLAUDE.md
@@ -2,83 +2,105 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build Commands
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Build the songbook PDF (two-pass for TOC)
|
||||
make
|
||||
# Build everything
|
||||
gradle build
|
||||
|
||||
# Remove auxiliary files
|
||||
make clean
|
||||
# Run all tests
|
||||
gradle test
|
||||
|
||||
# Remove everything including PDF
|
||||
make distclean
|
||||
# Run tests for a specific module
|
||||
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 → Validate → Measure → Paginate → Render
|
||||
|
||||
`SongbookPipeline` (in `app`) orchestrates the full flow:
|
||||
1. `ConfigParser` reads `songbook.yaml` → `BookConfig`
|
||||
2. `ChordProParser` reads `.chopro`/`.cho`/`.crd` files → `Song` objects
|
||||
3. `ForewordParser` reads optional `foreword.txt` → `Foreword` (if configured)
|
||||
4. `Validator` checks config and songs
|
||||
5. `MeasurementEngine` calculates each song's height in mm using `FontMetrics`
|
||||
6. `TocGenerator` estimates TOC page count and creates entries
|
||||
7. `PaginationEngine` arranges songs into pages (greedy spread packing)
|
||||
8. `PdfBookRenderer` generates the PDF via OpenPDF
|
||||
|
||||
**Module dependency graph:**
|
||||
```
|
||||
songbook.tex # Main document (title page, TOC, song inputs)
|
||||
songbook-style.sty # Style package (geometry, fonts, leadsheets config)
|
||||
songs/ # One .tex file per song
|
||||
fonts/ # Font files (UnifrakturMaguntia for titles)
|
||||
images/ # Filler images (empty for now)
|
||||
Makefile # Build rules (lualatex, two passes)
|
||||
output/ # Generated PDF (gitignored)
|
||||
model ← parser
|
||||
model ← layout
|
||||
model ← renderer-pdf
|
||||
parser, layout, renderer-pdf ← app
|
||||
app ← cli (Clikt)
|
||||
app, parser ← gui (Compose Desktop)
|
||||
```
|
||||
|
||||
## 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:
|
||||
- Song titles in Fraktur/blackletter font (UnifrakturMaguntia)
|
||||
- Chords above lyrics in regular weight, black
|
||||
- No verse labels (verses separated by blank lines)
|
||||
- Metadata (Worte/Weise) at bottom of each song page
|
||||
- Reference book cross-references (MO, PfLB) in footer
|
||||
- Each song starts on a new page
|
||||
- A5 twoside format with page numbers at bottom-outer
|
||||
**Pagination constraint:** Songs spanning 2 pages must start on a left (even) page. The `PaginationEngine` inserts filler images or blank pages to enforce this.
|
||||
|
||||
## Key Types
|
||||
|
||||
- `Song` → sections → `SongLine` → `LineSegment(chord?, text)` — chord is placed above the text segment. Also has `aliases`, `lyricist`, `composer`, `key`, `tags`, `notes: List<String>`, `references: Map<String, Int>` (bookId → page), `capo`
|
||||
- `SongLine` — holds `segments` plus optional `imagePath` (when set, the line is an inline image)
|
||||
- `Foreword` — `quote`, `paragraphs`, `signatures` — parsed from a plain-text file
|
||||
- `PageContent` — sealed class: `SongPage`, `FillerImage`, `BlankPage`, `ForewordPage`
|
||||
- `SectionType` — enum: `VERSE`, `CHORUS`, `BRIDGE`, `REPEAT`
|
||||
- `BookConfig` — top-level config with `FontsConfig`, `LayoutConfig`, `TocConfig`, `ForewordConfig`, `ReferenceBook` list. `FontSpec.file` supports custom font files. `LayoutConfig.metadataLabels` (`"abbreviated"` or `"german"`) and `metadataPosition` (`"top"` or `"bottom"`) control metadata rendering
|
||||
- `BuildResult` — returned by `SongbookPipeline.build()` with success/errors/counts
|
||||
|
||||
## Song Format
|
||||
|
||||
Each song uses the `leadsheets` `song` environment:
|
||||
ChordPro-compatible `.chopro`/`.cho`/`.crd` files: directives in `{braces}`, chords in `[brackets]` inline with lyrics, comments with `#`. See `songs/` for examples.
|
||||
|
||||
```latex
|
||||
\begin{song}{
|
||||
title = Song Title,
|
||||
lyrics = Lyricist,
|
||||
composer = Composer,
|
||||
key = G,
|
||||
mundorgel = 42,
|
||||
pfadfinderliederbuch = 118,
|
||||
note = {Optional note text.},
|
||||
}
|
||||
**Metadata directives:** `{title: }` / `{t: }`, `{alias: }`, `{lyricist: }`, `{composer: }`, `{key: }`, `{tags: }`, `{note: }`, `{capo: }`
|
||||
|
||||
\begin{verse}
|
||||
\chord{G}Lyrics with \chord{D}chords above. \\
|
||||
Next \chord{C}line here.
|
||||
\end{verse}
|
||||
**Section directives:** `{start_of_verse}` / `{sov}`, `{end_of_verse}` / `{eov}`, `{start_of_chorus}` / `{soc}`, `{end_of_chorus}` / `{eoc}`, `{start_of_repeat}` / `{sor}`, `{end_of_repeat}` / `{eor}`. Section starts accept an optional label. `{chorus}` inserts a chorus reference, `{repeat}` sets a repeat label.
|
||||
|
||||
\begin{verse}
|
||||
Second verse without chords (or with).
|
||||
\end{verse}
|
||||
**Notes block:** `{start_of_notes}` / `{son}` … `{end_of_notes}` / `{eon}` — multi-paragraph rich-text notes rendered at the end of a song.
|
||||
|
||||
\end{song}
|
||||
```
|
||||
**Inline image:** `{image: path}` — embeds an image within a song section.
|
||||
|
||||
**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)
|
||||
**Reference:** `{ref: bookId pageNumber}` — cross-reference to a page in another songbook (configured in `reference_books`).
|
||||
|
||||
## Style Details (songbook-style.sty)
|
||||
## Configuration
|
||||
|
||||
- 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)
|
||||
`songbook.yaml` at the project root. Key options beyond the basics:
|
||||
|
||||
- `fonts.<role>.file` — path to a custom font file (TTF/OTF) for any font role (`lyrics`, `chords`, `title`, `metadata`, `toc`)
|
||||
- `layout.metadata_labels` — `"abbreviated"` (M:/T:) or `"german"` (Worte:/Weise:)
|
||||
- `layout.metadata_position` — `"top"` (after title) or `"bottom"` (bottom of last page)
|
||||
- `toc.highlight_column` — abbreviation of the reference-book column to highlight (e.g. `"CL"`)
|
||||
- `foreword.file` — path to a foreword text file (default `./foreword.txt`)
|
||||
- `reference_books` — list of `{id, name, abbreviation}` for cross-reference columns in the TOC
|
||||
- `songs.order` — `"alphabetical"` or `"manual"` (file-system order)
|
||||
|
||||
## Test Patterns
|
||||
|
||||
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.
|
||||
|
||||
## Package
|
||||
|
||||
All code under `de.pfadfinder.songbook.*` — subpackages match module names (`.model`, `.parser`, `.layout`, `.renderer.pdf`, `.app`, `.cli`, `.gui`).
|
||||
|
||||
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,259 @@
|
||||
package de.pfadfinder.songbook.app
|
||||
|
||||
import de.pfadfinder.songbook.model.*
|
||||
import de.pfadfinder.songbook.parser.*
|
||||
import de.pfadfinder.songbook.parser.ForewordParser
|
||||
import de.pfadfinder.songbook.layout.*
|
||||
import de.pfadfinder.songbook.renderer.pdf.PdfBookRenderer
|
||||
import de.pfadfinder.songbook.renderer.pdf.PdfFontMetrics
|
||||
import de.pfadfinder.songbook.renderer.pdf.TocRenderer
|
||||
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) {
|
||||
|
||||
/**
|
||||
* Build the songbook PDF.
|
||||
*
|
||||
* @param customSongOrder Optional list of song file names in the desired order.
|
||||
* When provided, songs are sorted to match this order instead of using the
|
||||
* config-based sort (alphabetical or manual). Files not in this list are
|
||||
* appended at the end.
|
||||
* @param onProgress Optional callback invoked with status messages during the build.
|
||||
*/
|
||||
fun build(customSongOrder: List<String>? = null, onProgress: ((String) -> Unit)? = null): BuildResult {
|
||||
// 1. Parse config
|
||||
onProgress?.invoke("Konfiguration wird geladen...")
|
||||
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 rawConfig = ConfigParser.parse(configFile)
|
||||
|
||||
// Resolve font file paths relative to the project directory
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
|
||||
// Validate config (including font file existence)
|
||||
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")))
|
||||
}
|
||||
|
||||
onProgress?.invoke("Lieder werden importiert (${songFiles.size} Dateien)...")
|
||||
logger.info { "Found ${songFiles.size} song files" }
|
||||
|
||||
val songsByFileName = mutableMapOf<String, Song>()
|
||||
val allErrors = mutableListOf<ValidationError>()
|
||||
|
||||
for ((index, file) in songFiles.withIndex()) {
|
||||
if (index > 0 && index % 50 == 0) {
|
||||
onProgress?.invoke("Lieder werden importiert... ($index/${songFiles.size})")
|
||||
}
|
||||
try {
|
||||
val song = ChordProParser.parseFile(file)
|
||||
val songErrors = Validator.validateSong(song, file.name)
|
||||
if (songErrors.isNotEmpty()) {
|
||||
allErrors.addAll(songErrors)
|
||||
} else {
|
||||
songsByFileName[file.name] = song
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.isNotEmpty()) {
|
||||
return BuildResult(false, errors = allErrors)
|
||||
}
|
||||
|
||||
val songs = songsByFileName.values.toList()
|
||||
|
||||
// Sort songs: custom order takes priority, then config-based sort
|
||||
val sortedSongs = if (customSongOrder != null) {
|
||||
val orderMap = customSongOrder.withIndex().associate { (index, name) -> name to index }
|
||||
songs.sortedBy { song ->
|
||||
val fileName = songsByFileName.entries.find { it.value === song }?.key
|
||||
orderMap[fileName] ?: Int.MAX_VALUE
|
||||
}
|
||||
} else {
|
||||
when (config.songs.order) {
|
||||
"alphabetical" -> songs.sortedBy { it.title.lowercase() }
|
||||
else -> songs // manual order = file order
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "Parsed ${sortedSongs.size} songs" }
|
||||
|
||||
// 2b. Parse foreword (if configured)
|
||||
var foreword: Foreword? = null
|
||||
val forewordConfig = config.foreword
|
||||
if (forewordConfig != null) {
|
||||
val forewordFile = File(projectDir, forewordConfig.file)
|
||||
if (forewordFile.exists()) {
|
||||
logger.info { "Parsing foreword: ${forewordFile.absolutePath}" }
|
||||
foreword = ForewordParser.parseFile(forewordFile)
|
||||
} else {
|
||||
logger.warn { "Foreword file not found: ${forewordFile.absolutePath}" }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Measure songs
|
||||
onProgress?.invoke("Layout wird berechnet...")
|
||||
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 estimatedTocPages = tocGenerator.estimateTocPages(sortedSongs)
|
||||
|
||||
// Intro page takes 2 pages (title + blank back) for double-sided printing
|
||||
val introPages = if (config.intro?.enabled == true) 2 else 0
|
||||
// Foreword always takes 2 pages (for double-sided printing)
|
||||
val forewordPages = if (foreword != null) 2 else 0
|
||||
|
||||
val headerPages = introPages + estimatedTocPages + forewordPages
|
||||
val paginationEngine = PaginationEngine(config)
|
||||
val pages = paginationEngine.paginate(measuredSongs, headerPages)
|
||||
|
||||
// Generate initial TOC entries, then measure actual pages needed
|
||||
val initialTocEntries = tocGenerator.generate(pages, headerPages)
|
||||
val tocRenderer = TocRenderer(fontMetrics, config)
|
||||
val tocPages = tocRenderer.measurePages(initialTocEntries)
|
||||
|
||||
// Re-generate TOC entries with corrected page offset if count changed.
|
||||
// Since tocPages is always even, the pagination layout (left/right parity)
|
||||
// stays the same — only page numbers in the TOC entries need updating.
|
||||
val actualHeaderPages = introPages + tocPages + forewordPages
|
||||
val tocEntries = if (tocPages != estimatedTocPages) {
|
||||
logger.info { "TOC pages: estimated $estimatedTocPages, actual $tocPages" }
|
||||
tocGenerator.generate(pages, actualHeaderPages)
|
||||
} else {
|
||||
initialTocEntries
|
||||
}
|
||||
|
||||
// Build final page list with foreword pages inserted before song content
|
||||
val allPages = mutableListOf<PageContent>()
|
||||
if (foreword != null) {
|
||||
allPages.add(PageContent.ForewordPage(foreword, 0))
|
||||
allPages.add(PageContent.ForewordPage(foreword, 1))
|
||||
}
|
||||
allPages.addAll(pages)
|
||||
|
||||
val layoutResult = LayoutResult(
|
||||
introPages = introPages,
|
||||
tocPages = tocPages,
|
||||
pages = allPages,
|
||||
tocEntries = tocEntries
|
||||
)
|
||||
|
||||
val totalPages = introPages + tocPages + pages.size
|
||||
logger.info { "Layout: ${introPages} intro, ${tocPages} TOC, ${pages.size} content pages" }
|
||||
|
||||
// 5. Render PDF
|
||||
onProgress?.invoke("PDF wird erzeugt (${sortedSongs.size} Lieder, $totalPages Seiten)...")
|
||||
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, $totalPages pages" }
|
||||
|
||||
return BuildResult(
|
||||
success = true,
|
||||
outputFile = outputFile,
|
||||
songCount = sortedSongs.size,
|
||||
pageCount = totalPages
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves font file paths relative to the project directory.
|
||||
* If a FontSpec has a `file` property, it is resolved against projectDir
|
||||
* to produce an absolute path.
|
||||
*/
|
||||
private fun resolveFontPaths(config: BookConfig): BookConfig {
|
||||
fun FontSpec.resolveFile(): FontSpec {
|
||||
val fontFile = this.file ?: return this
|
||||
val fontFileObj = File(fontFile)
|
||||
// Only resolve relative paths; absolute paths are left as-is
|
||||
if (fontFileObj.isAbsolute) return this
|
||||
val resolved = File(projectDir, fontFile)
|
||||
return this.copy(file = resolved.absolutePath)
|
||||
}
|
||||
|
||||
val resolvedFonts = config.fonts.copy(
|
||||
lyrics = config.fonts.lyrics.resolveFile(),
|
||||
chords = config.fonts.chords.resolveFile(),
|
||||
title = config.fonts.title.resolveFile(),
|
||||
metadata = config.fonts.metadata.resolveFile(),
|
||||
toc = config.fonts.toc.resolveFile()
|
||||
)
|
||||
return config.copy(fonts = resolvedFonts)
|
||||
}
|
||||
|
||||
fun validate(): List<ValidationError> {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
if (!configFile.exists()) {
|
||||
return listOf(ValidationError(configFile.name, null, "songbook.yaml not found"))
|
||||
}
|
||||
|
||||
val rawConfig = ConfigParser.parse(configFile)
|
||||
val config = resolveFontPaths(rawConfig)
|
||||
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(onProgress = { msg -> echo(msg) })
|
||||
|
||||
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
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
21
gui/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
||||
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")
|
||||
implementation("org.apache.pdfbox:pdfbox:3.0.4")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "de.pfadfinder.songbook.gui.AppKt"
|
||||
}
|
||||
}
|
||||
447
gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt
Normal file
@@ -0,0 +1,447 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
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.ConfigParser
|
||||
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 originalSongs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
|
||||
var songsOrderConfig by remember { mutableStateOf("alphabetical") }
|
||||
var isCustomOrder by remember { mutableStateOf(false) }
|
||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var isLoadingSongs by remember { mutableStateOf(false) }
|
||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||
val previewState = remember { PdfPreviewState() }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val reorderEnabled = songsOrderConfig != "alphabetical"
|
||||
|
||||
fun loadSongs(path: String) {
|
||||
val projectDir = File(path)
|
||||
songs = emptyList()
|
||||
originalSongs = emptyList()
|
||||
isCustomOrder = false
|
||||
if (!projectDir.isDirectory) return
|
||||
|
||||
isLoadingSongs = true
|
||||
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
|
||||
|
||||
scope.launch {
|
||||
val (loadedSongs, order) = withContext(Dispatchers.IO) {
|
||||
val configFile = File(projectDir, "songbook.yaml")
|
||||
var songsDir: File
|
||||
var orderConfig = "alphabetical"
|
||||
|
||||
if (configFile.exists()) {
|
||||
try {
|
||||
val config = ConfigParser.parse(configFile)
|
||||
songsDir = File(projectDir, config.songs.directory)
|
||||
orderConfig = config.songs.order
|
||||
} catch (_: Exception) {
|
||||
songsDir = File(projectDir, "songs")
|
||||
}
|
||||
} else {
|
||||
songsDir = File(projectDir, "songs")
|
||||
}
|
||||
|
||||
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig)
|
||||
|
||||
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
val loaded = 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)")
|
||||
}
|
||||
}
|
||||
Pair(loaded, orderConfig)
|
||||
}
|
||||
|
||||
songsOrderConfig = order
|
||||
songs = if (order == "alphabetical") {
|
||||
loadedSongs.sortedBy { it.title.lowercase() }
|
||||
} else {
|
||||
loadedSongs
|
||||
}
|
||||
originalSongs = songs.toList()
|
||||
isLoadingSongs = false
|
||||
statusMessages = if (loadedSongs.isNotEmpty()) {
|
||||
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
|
||||
} else {
|
||||
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
SelectionContainer {
|
||||
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))
|
||||
|
||||
// Central content area: song list or preview panel
|
||||
if (previewState.isVisible) {
|
||||
// Show preview panel
|
||||
PdfPreviewPanel(
|
||||
state = previewState,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
// Song list header with optional reset button
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Lieder (${songs.size}):",
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (reorderEnabled && isCustomOrder) {
|
||||
Button(
|
||||
onClick = {
|
||||
songs = originalSongs.toList()
|
||||
isCustomOrder = false
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("Reihenfolge zuruecksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (isLoadingSongs) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Lieder werden geladen...", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
} else if (songs.isEmpty() && projectPath.isNotBlank()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Text(
|
||||
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (projectPath.isBlank()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Text(
|
||||
"Bitte ein Projektverzeichnis auswaehlen.",
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ReorderableSongList(
|
||||
songs = songs,
|
||||
reorderEnabled = reorderEnabled,
|
||||
onReorder = { newList ->
|
||||
songs = newList
|
||||
isCustomOrder = true
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Build custom song order from the current GUI list
|
||||
val customOrder = if (isCustomOrder) {
|
||||
songs.map { it.fileName }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
|
||||
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
|
||||
}
|
||||
} 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
|
||||
|
||||
// Automatically load preview after successful build
|
||||
if (result.success && result.outputFile != null) {
|
||||
previewState.loadPdf(result.outputFile!!)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning && projectPath.isNotBlank()
|
||||
) {
|
||||
Text("Buch erstellen")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (projectPath.isBlank()) return@Button
|
||||
isRunning = true
|
||||
lastBuildResult = null
|
||||
statusMessages = listOf(StatusMessage("Validierung laeuft...", 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 geoeffnet werden: ${e.message}",
|
||||
MessageType.ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text("PDF oeffnen")
|
||||
}
|
||||
|
||||
// Show/hide preview button
|
||||
Button(
|
||||
onClick = {
|
||||
if (previewState.isVisible) {
|
||||
previewState.isVisible = false
|
||||
} else {
|
||||
scope.launch {
|
||||
if (previewState.totalPages == 0) {
|
||||
lastBuildResult?.outputFile?.let { previewState.loadPdf(it) }
|
||||
} else {
|
||||
previewState.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text(if (previewState.isVisible) "Vorschau ausblenden" else "Vorschau")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,221 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.pdfbox.Loader
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.rendering.PDFRenderer
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* State holder for the PDF preview. Manages the current page, total page count,
|
||||
* and a cache of rendered page images.
|
||||
*/
|
||||
class PdfPreviewState {
|
||||
var pdfFile: File? by mutableStateOf(null)
|
||||
private set
|
||||
var totalPages: Int by mutableStateOf(0)
|
||||
private set
|
||||
var currentPage: Int by mutableStateOf(0)
|
||||
private set
|
||||
var isLoading: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
var currentImage: ImageBitmap? by mutableStateOf(null)
|
||||
private set
|
||||
var isVisible: Boolean by mutableStateOf(false)
|
||||
|
||||
private var document: PDDocument? = null
|
||||
private var renderer: PDFRenderer? = null
|
||||
private val pageCache = mutableMapOf<Int, ImageBitmap>()
|
||||
|
||||
/**
|
||||
* Load a new PDF file for preview. Resets state and renders the first page.
|
||||
*/
|
||||
suspend fun loadPdf(file: File) {
|
||||
close()
|
||||
pdfFile = file
|
||||
currentPage = 0
|
||||
pageCache.clear()
|
||||
currentImage = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val doc = Loader.loadPDF(file)
|
||||
document = doc
|
||||
renderer = PDFRenderer(doc)
|
||||
totalPages = doc.numberOfPages
|
||||
} catch (_: Exception) {
|
||||
totalPages = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPages > 0) {
|
||||
isVisible = true
|
||||
renderCurrentPage()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun goToPage(page: Int) {
|
||||
if (page < 0 || page >= totalPages) return
|
||||
currentPage = page
|
||||
renderCurrentPage()
|
||||
}
|
||||
|
||||
suspend fun nextPage() {
|
||||
goToPage(currentPage + 1)
|
||||
}
|
||||
|
||||
suspend fun previousPage() {
|
||||
goToPage(currentPage - 1)
|
||||
}
|
||||
|
||||
private suspend fun renderCurrentPage() {
|
||||
val cached = pageCache[currentPage]
|
||||
if (cached != null) {
|
||||
currentImage = cached
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
try {
|
||||
val image = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val pdfRenderer = renderer ?: return@withContext null
|
||||
// Render at 150 DPI for a good balance of quality and speed
|
||||
val bufferedImage: BufferedImage = pdfRenderer.renderImageWithDPI(currentPage, 150f)
|
||||
bufferedImage.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image != null) {
|
||||
pageCache[currentPage] = image
|
||||
currentImage = image
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
document?.close()
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
document = null
|
||||
renderer = null
|
||||
pageCache.clear()
|
||||
currentImage = null
|
||||
totalPages = 0
|
||||
currentPage = 0
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PdfPreviewPanel(
|
||||
state: PdfPreviewState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Header with title and close button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Vorschau",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Button(
|
||||
onClick = { state.isVisible = false },
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("Schliessen")
|
||||
}
|
||||
}
|
||||
|
||||
// Page image area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFFE0E0E0)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (state.currentImage != null) {
|
||||
Image(
|
||||
bitmap = state.currentImage!!,
|
||||
contentDescription = "Seite ${state.currentPage + 1}",
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Keine Vorschau verfuegbar",
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { state.previousPage() }
|
||||
},
|
||||
enabled = state.currentPage > 0 && !state.isLoading
|
||||
) {
|
||||
Text("<")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Seite ${state.currentPage + 1} / ${state.totalPages}",
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(min = 120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { state.nextPage() }
|
||||
},
|
||||
enabled = state.currentPage < state.totalPages - 1 && !state.isLoading
|
||||
) {
|
||||
Text(">")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package de.pfadfinder.songbook.gui
|
||||
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* A song list that supports drag-and-drop reordering when enabled.
|
||||
*
|
||||
* @param songs The current song list
|
||||
* @param reorderEnabled Whether drag-and-drop is enabled
|
||||
* @param onReorder Callback when songs are reordered, provides the new list
|
||||
*/
|
||||
@Composable
|
||||
fun ReorderableSongList(
|
||||
songs: List<SongEntry>,
|
||||
reorderEnabled: Boolean,
|
||||
onReorder: (List<SongEntry>) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Track drag state
|
||||
var draggedIndex by remember { mutableStateOf(-1) }
|
||||
var hoverIndex by remember { mutableStateOf(-1) }
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
|
||||
// Approximate item height for calculating target index from drag offset
|
||||
val itemHeightPx = 36f // approximate height of each row in pixels
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(end = 12.dp)
|
||||
) {
|
||||
itemsIndexed(songs, key = { _, song -> song.fileName }) { index, song ->
|
||||
val isDragTarget = hoverIndex == index && draggedIndex != -1 && draggedIndex != index
|
||||
val isBeingDragged = draggedIndex == index
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isDragTarget) {
|
||||
Modifier.background(Color(0xFFBBDEFB)) // light blue drop indicator
|
||||
} else if (isBeingDragged) {
|
||||
Modifier.background(Color(0xFFE0E0E0)) // grey for dragged item
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(vertical = 2.dp, horizontal = 8.dp)
|
||||
.then(
|
||||
if (reorderEnabled) {
|
||||
Modifier.pointerInput(songs) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggedIndex = index
|
||||
hoverIndex = index
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount.y
|
||||
// Calculate target index based on cumulative drag offset
|
||||
val indexDelta = (dragOffset / itemHeightPx).toInt()
|
||||
val newHover = (draggedIndex + indexDelta).coerceIn(0, songs.size - 1)
|
||||
hoverIndex = newHover
|
||||
},
|
||||
onDragEnd = {
|
||||
if (draggedIndex != -1 && hoverIndex != -1 && draggedIndex != hoverIndex) {
|
||||
val mutable = songs.toMutableList()
|
||||
val item = mutable.removeAt(draggedIndex)
|
||||
mutable.add(hoverIndex, item)
|
||||
onReorder(mutable)
|
||||
}
|
||||
draggedIndex = -1
|
||||
hoverIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = -1
|
||||
hoverIndex = -1
|
||||
dragOffset = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (reorderEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Ziehen zum Verschieben",
|
||||
modifier = Modifier.size(16.dp).padding(end = 4.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
Text(song.title, modifier = Modifier.weight(1f))
|
||||
Text(song.fileName, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (!reorderEnabled && songs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Reihenfolge ist alphabetisch. Wechsle in songbook.yaml zu songs.order: \"manual\" um die Reihenfolge zu aendern.",
|
||||
color = Color.Gray,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = rememberScrollbarAdapter(listState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
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 |