Compare commits

...

2 commits

Author SHA1 Message Date
Thorsten Bus 2a68d098a2 Merge branch 'feature/playlist-arrangement' into master
Honor per-service arrangement selection in ProPresenter export:
- .pro selected_arrangement (protobuf field 10) via existing parser API
- .proplaylist item arrangement_name (field 5), import-stable by name
- buildGroups emits union of all arrangements' sections

Conflict in PlaylistExportService.php (agenda song branch) resolved by
applying arrangement selection inside master's new else-block; legacy
path and ProExport/ProBundle changes auto-merged. All 63 export tests
pass against the hot-patched vendor.
2026-07-06 09:18:35 +02:00
Thorsten Bus 4e548bbeed feat(export): honor per-service arrangement
ProPresenter export ignored service_songs.song_arrangement_id and
always used the default/is_default arrangement. Now the chosen
arrangement is pre-selected while ALL arrangements stay in the file,
so users can still switch in ProPresenter.

Import-stable, by NAME (avoids the fragile playlist arrangement UUID
that breaks on import):
- .pro: set selected_arrangement (protobuf field 10) to the chosen
  arrangement via Song::setSelectedArrangementUuid(getArrangementByName).
- .proplaylist: set the presentation item arrangement_name (field 5).

buildGroups now emits the UNION of sections across all arrangements
(selected first, in play order, with first/last-slide macro anchoring;
other arrangements' sections appended without position macros) so every
arrangement's group-name refs resolve instead of being silently dropped.

No parser package changes; installed public API only. Library/no-service
context keeps default-arrangement behaviour unchanged.
2026-07-05 23:16:59 +02:00
4 changed files with 397 additions and 60 deletions

View file

@ -110,7 +110,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$skippedUnmatched++; $skippedUnmatched++;
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
} else { } else {
$proPath = $exportService->generateProFile($song, $service); $selectedArr = $serviceSong->arrangement
?? ($song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first());
$proPath = $exportService->generateProFile($song, $service, $selectedArr);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename; $destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath); rename($proPath, $destPath);
@ -118,11 +121,15 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$embeddedFiles[$proFilename] = file_get_contents($destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles); $this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [ $playlistItem = [
'type' => 'presentation', 'type' => 'presentation',
'name' => $song->title, 'name' => $song->title,
'path' => $proFilename, 'path' => $proFilename,
]; ];
if ($selectedArr?->name !== null) {
$playlistItem['arrangement_name'] = $selectedArr->name;
}
$playlistItems[] = $playlistItem;
$realContentEmitted = true; $realContentEmitted = true;
} }
@ -268,7 +275,12 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
$matchedSongs = $service->serviceSongs() $matchedSongs = $service->serviceSongs()
->whereNotNull('song_id') ->whereNotNull('song_id')
->orderBy('order') ->orderBy('order')
->with('song.arrangements.arrangementSections.section.slides', 'song.arrangements.arrangementSections.section.label') ->with(
'song.arrangements.arrangementSections.section.slides',
'song.arrangements.arrangementSections.section.label',
'arrangement.arrangementSections.section.slides',
'arrangement.arrangementSections.section.label',
)
->get(); ->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
@ -303,7 +315,10 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
continue; continue;
} }
$proPath = $exportService->generateProFile($song, $service); $selectedArr = $serviceSong->arrangement
?? ($song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first());
$proPath = $exportService->generateProFile($song, $service, $selectedArr);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename; $destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath); rename($proPath, $destPath);
@ -311,11 +326,15 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
$embeddedFiles[$proFilename] = file_get_contents($destPath); $embeddedFiles[$proFilename] = file_get_contents($destPath);
$this->embedBackground($service, $embeddedFiles); $this->embedBackground($service, $embeddedFiles);
$playlistItems[] = [ $playlistItem = [
'type' => 'presentation', 'type' => 'presentation',
'name' => $song->title, 'name' => $song->title,
'path' => $proFilename, 'path' => $proFilename,
]; ];
if ($selectedArr?->name !== null) {
$playlistItem['arrangement_name'] = $selectedArr->name;
}
$playlistItems[] = $playlistItem;
} }
$this->addSlidePresentation( $this->addSlidePresentation(

View file

@ -45,6 +45,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
'slides', 'slides',
'serviceSong.song.arrangements.arrangementSections.section.slides', 'serviceSong.song.arrangements.arrangementSections.section.slides',
'serviceSong.song.arrangements.arrangementSections.section.label', 'serviceSong.song.arrangements.arrangementSections.section.label',
'serviceSong.arrangement.arrangementSections.section.slides',
'serviceSong.arrangement.arrangementSections.section.label',
]); ]);
$title = $agendaItem->title ?: 'Ablauf-Element'; $title = $agendaItem->title ?: 'Ablauf-Element';
@ -56,7 +58,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
throw new RuntimeException('Lied "'.$song->title.'" übersprungen: keine Inhaltsfolien.'); throw new RuntimeException('Lied "'.$song->title.'" übersprungen: keine Inhaltsfolien.');
} }
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service); $selected = $agendaItem->serviceSong->arrangement;
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service, $selected);
$proFilename = self::safeFilename($song->title).'.pro'; $proFilename = self::safeFilename($song->title).'.pro';
$songMediaFiles = []; $songMediaFiles = [];

View file

@ -4,7 +4,9 @@
use App\Models\Service; use App\Models\Service;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileWriter;
class ProExportService class ProExportService
{ {
@ -13,91 +15,72 @@ public function __construct(
private readonly ServiceImageResolver $imageResolver, private readonly ServiceImageResolver $imageResolver,
) {} ) {}
public function generateProFile(Song $song, ?Service $service = null): string public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string
{ {
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro'; $tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
ProFileGenerator::generateAndWrite( $parserSong = $this->generateParserSong($song, $service, $selected);
$tempPath, ProFileWriter::write($parserSong, $tempPath);
$song->title,
$this->buildGroups($song, $service),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
return $tempPath; return $tempPath;
} }
public function generateParserSong(Song $song, ?Service $service = null): \ProPresenter\Parser\Song public function generateParserSong(Song $song, ?Service $service = null, ?SongArrangement $selected = null): \ProPresenter\Parser\Song
{ {
$song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); $song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
return ProFileGenerator::generate( $parserSong = ProFileGenerator::generate(
$song->title, $song->title,
$this->buildGroups($song, $service), $this->buildGroups($song, $service, $selected),
$this->buildArrangements($song), $this->buildArrangements($song),
$this->buildCcliMetadata($song), $this->buildCcliMetadata($song),
); );
// Pre-select the chosen arrangement (protobuf field 10) by name so the export honours the
// per-service selection while keeping ALL arrangements available in the .pro file.
$chosen = $this->chosenArrangement($song, $selected);
if ($chosen !== null) {
$arrangement = $parserSong->getArrangementByName($chosen->name);
if ($arrangement !== null) {
$parserSong->setSelectedArrangementUuid($arrangement->getUuid());
}
}
return $parserSong;
} }
private function buildGroups(Song $song, ?Service $service = null): array private function buildGroups(Song $song, ?Service $service = null, ?SongArrangement $selected = null): array
{ {
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); $selectedArr = $this->chosenArrangement($song, $selected);
if ($defaultArr === null) { if ($selectedArr === null) {
return []; return [];
} }
$defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
$groups = [];
$seenSectionIds = [];
$background = $this->backgroundData($service); $background = $this->backgroundData($service);
// Pre-compute the total slide count across the whole song (same filtering as the loop below) // Ordered, de-duplicated sections of the SELECTED arrangement (its play order).
// so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide. $selectedSections = $this->uniqueSectionsFor($selectedArr);
$selectedSectionIds = array_map(static fn ($section) => $section->id, $selectedSections);
// Total slide count across the selected arrangement's play order so that 'first_slide'
// and 'last_slide' macros anchor to the selected arrangement's very first/last slide.
$totalSlidesInSong = 0; $totalSlidesInSong = 0;
$seenForCount = []; foreach ($selectedSections as $section) {
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if (in_array($section->id, $seenForCount, true)) {
continue;
}
$seenForCount[] = $section->id;
$totalSlidesInSong += $section->slides->count(); $totalSlidesInSong += $section->slides->count();
} }
$groups = [];
$globalSlideIndex = 0; $globalSlideIndex = 0;
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { // 1) Emit the selected arrangement's sections first, with position-aware macros.
$section = $arrangementSection->section; foreach ($selectedSections as $section) {
$label = $section?->label; $label = $section->label;
if ($section === null || $label === null) {
continue;
}
if (in_array($section->id, $seenSectionIds, true)) {
continue;
}
$seenSectionIds[] = $section->id;
$slides = []; $slides = [];
$sectionSlides = $section->slides->sortBy('order')->values();
foreach ($sectionSlides as $slide) { foreach ($section->slides->sortBy('order')->values() as $slide) {
$slideData = ['text' => $slide->text_content ?? '']; $slideData = $this->baseSlideData($slide, $background);
if ($slide->text_content_translated) {
$slideData['translation'] = $slide->text_content_translated;
}
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
$slideData['background'] = $background;
}
if ($service !== null) { if ($service !== null) {
$macros = $this->macroResolutionService->macrosForSlide( $macros = $this->macroResolutionService->macrosForSlide(
@ -123,9 +106,110 @@ private function buildGroups(Song $song, ?Service $service = null): array
]; ];
} }
// 2) Append sections referenced by OTHER arrangements so every arrangement's group-name refs
// resolve in the parser (unknown group names are silently dropped otherwise). These sections
// are not part of the selected play order → no position-based macros, no slide-index advance.
foreach ($this->appendedSections($song, $selectedSectionIds) as $section) {
$slides = [];
foreach ($section->slides->sortBy('order')->values() as $slide) {
$slides[] = $this->baseSlideData($slide, $background);
}
$groups[] = [
'name' => $section->label->name,
'color' => ProImportService::hexToRgba($section->label->color ?? '#808080'),
'slides' => $slides,
];
}
return $groups; return $groups;
} }
/**
* Resolve the chosen arrangement: explicit selection default (is_default) first.
*/
private function chosenArrangement(Song $song, ?SongArrangement $selected): ?SongArrangement
{
return $selected
?? $song->arrangements->firstWhere('is_default', true)
?? $song->arrangements->first();
}
/**
* Ordered, de-duplicated valid sections (section + label present) of a single arrangement.
*
* @return array<int, \App\Models\SongSection>
*/
private function uniqueSectionsFor(SongArrangement $arrangement): array
{
$arrangement->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label');
$sections = [];
$seen = [];
foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if (in_array($section->id, $seen, true)) {
continue;
}
$seen[] = $section->id;
$sections[] = $section;
}
return $sections;
}
/**
* Sections used by any arrangement but NOT already emitted for the selected arrangement.
* Stable order: arrangements in song order, sections in each arrangement's play order.
*
* @param array<int, int> $excludeSectionIds
* @return array<int, \App\Models\SongSection>
*/
private function appendedSections(Song $song, array $excludeSectionIds): array
{
$sections = [];
$seen = $excludeSectionIds;
foreach ($song->arrangements as $arrangement) {
foreach ($this->uniqueSectionsFor($arrangement) as $section) {
if (in_array($section->id, $seen, true)) {
continue;
}
$seen[] = $section->id;
$sections[] = $section;
}
}
return $sections;
}
/**
* Base slideData shared by selected and appended sections: text, optional translation, optional background.
*/
private function baseSlideData(object $slide, ?array $background): array
{
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
$slideData['translation'] = $slide->text_content_translated;
}
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
$slideData['background'] = $background;
}
return $slideData;
}
private function backgroundData(?Service $service): ?array private function backgroundData(?Service $service): ?array
{ {
if ($service === null) { if ($service === null) {

View file

@ -0,0 +1,231 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
/**
* Export must honour the per-service selected arrangement:
* - .pro file: selected_arrangement (protobuf field 10) set to the chosen arrangement by name.
* - .proplaylist: presentation item arrangement_name (field 5) set to the chosen arrangement name.
* ALL arrangements stay in the .pro; only the pre-selection changes.
*/
final class ArrangementSelectionExportTest extends TestCase
{
use RefreshDatabase;
/**
* Song with three sections and two arrangements whose section sets DIFFER:
* - "Normal" (default): Strophe Refrain
* - "Bene" (chosen): Refrain Bridge
* Union of both arrangements = {Strophe, Refrain, Bridge}.
*
* @return array{0: Song, 1: SongArrangement, 2: SongArrangement}
*/
private function createSongWithTwoArrangements(string $title = 'Union Test'): array
{
$song = Song::create([
'title' => $title,
'ccli_id' => (string) fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$verseLabel = Label::firstOrCreate(['name' => 'Strophe 1 - '.$title], ['color' => '#2196F3']);
$chorusLabel = Label::firstOrCreate(['name' => 'Refrain - '.$title], ['color' => '#F44336']);
$bridgeLabel = Label::firstOrCreate(['name' => 'Bridge - '.$title], ['color' => '#4CAF50']);
$verse = $song->sections()->create(['label_id' => $verseLabel->id, 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'Strophe Zeile']);
$chorus = $song->sections()->create(['label_id' => $chorusLabel->id, 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Refrain Zeile']);
$bridge = $song->sections()->create(['label_id' => $bridgeLabel->id, 'order' => 2]);
$bridge->slides()->create(['order' => 0, 'text_content' => 'Bridge Zeile']);
$normal = $song->arrangements()->create(['name' => 'Normal', 'is_default' => true]);
$normal->arrangementSections()->create(['song_section_id' => $verse->id, 'order' => 0]);
$normal->arrangementSections()->create(['song_section_id' => $chorus->id, 'order' => 1]);
$bene = $song->arrangements()->create(['name' => 'Bene', 'is_default' => false]);
$bene->arrangementSections()->create(['song_section_id' => $chorus->id, 'order' => 0]);
$bene->arrangementSections()->create(['song_section_id' => $bridge->id, 'order' => 1]);
return [$song->fresh(), $normal, $bene];
}
public function test_generate_parser_song_mit_gewaehltem_arrangement_setzt_selected_und_behaelt_alle(): void
{
[$song, $normal, $bene] = $this->createSongWithTwoArrangements();
$parserSong = app(ProExportService::class)->generateParserSong($song, null, $bene);
$beneParser = $parserSong->getArrangementByName('Bene');
$this->assertNotNull($beneParser, 'Gewähltes Arrangement "Bene" muss im .pro vorhanden sein');
$this->assertSame(
$beneParser->getUuid(),
$parserSong->getSelectedArrangementUuid(),
'selected_arrangement muss auf das gewählte Arrangement "Bene" zeigen',
);
$names = array_map(fn ($arr) => $arr->getName(), $parserSong->getArrangements());
$this->assertContains('Normal', $names, 'Default-Arrangement muss erhalten bleiben');
$this->assertContains('Bene', $names, 'Gewähltes Arrangement muss erhalten bleiben');
}
public function test_generate_parser_song_ohne_auswahl_nutzt_default_arrangement(): void
{
[$song, $normal] = $this->createSongWithTwoArrangements();
$parserSong = app(ProExportService::class)->generateParserSong($song, null, null);
$normalParser = $parserSong->getArrangementByName('Normal');
$this->assertNotNull($normalParser);
$this->assertSame(
$normalParser->getUuid(),
$parserSong->getSelectedArrangementUuid(),
'Ohne explizite Auswahl muss das Default-Arrangement vorausgewählt sein',
);
}
public function test_union_der_sektionen_laesst_jedes_arrangement_gruppen_rendern(): void
{
[$song, $normal, $bene] = $this->createSongWithTwoArrangements();
// Auswahl "Bene" (Refrain, Bridge) — ohne Union würde "Normal" (Strophe) leer/unvollständig rendern.
$parserSong = app(ProExportService::class)->generateParserSong($song, null, $bene);
// Alle drei Sektionen (Strophe, Refrain, Bridge) als Gruppen emittiert.
$this->assertCount(3, $parserSong->getGroups(), 'Union muss alle referenzierten Sektionen als Gruppen emittieren');
foreach ($parserSong->getArrangements() as $arrangement) {
$resolved = $parserSong->getGroupsForArrangement($arrangement);
$this->assertCount(
2,
$resolved,
"Arrangement '{$arrangement->getName()}' muss alle 2 Gruppen-Referenzen auflösen können",
);
}
}
public function test_playlist_export_setzt_arrangement_name_auf_gewaehltes_arrangement(): void
{
Storage::fake('public');
$service = Service::factory()->create(['title' => 'Arrangement Service', 'date' => now()]);
[$song, $normal, $bene] = $this->createSongWithTwoArrangements('Playlist Song');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $bene->id,
'cts_song_name' => 'Playlist Song',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Playlist Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$songEntry = null;
foreach ($playlist->getEntries() as $entry) {
if ($entry->getName() === 'Playlist Song') {
$songEntry = $entry;
break;
}
}
$this->assertNotNull($songEntry, 'Song-Eintrag muss in der Playlist vorhanden sein');
$this->assertSame('Bene', $songEntry->getArrangementName(), 'arrangement_name muss das gewählte Arrangement sein');
// Und der eingebettete .pro trägt selected_arrangement = "Bene".
$embedded = $playlist->getEmbeddedSong('Playlist Song.pro');
$this->assertNotNull($embedded);
$this->assertSame(
$embedded->getArrangementByName('Bene')->getUuid(),
$embedded->getSelectedArrangementUuid(),
);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_agenda_bundle_export_selektiert_gewaehltes_arrangement(): void
{
Storage::fake('public');
$service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]);
[$song, $normal, $bene] = $this->createSongWithTwoArrangements('Bundle Song');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $bene->id,
'cts_song_name' => 'Bundle Song',
'order' => 1,
]);
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Bundle Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
$bundleSong = ProBundleReader::read($bundlePath)->getSong();
$beneParser = $bundleSong->getArrangementByName('Bene');
$this->assertNotNull($beneParser, 'Bundle .pro muss das Arrangement "Bene" enthalten');
$this->assertSame(
$beneParser->getUuid(),
$bundleSong->getSelectedArrangementUuid(),
'Bundle .pro selected_arrangement muss "Bene" sein',
);
$this->assertNotNull($bundleSong->getArrangementByName('Normal'), 'Alle Arrangements bleiben erhalten');
@unlink($bundlePath);
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}