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