diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 5078ef2..a9e9bbd 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -114,7 +114,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda 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'; $destPath = $tempDir.'/'.$proFilename; rename($proPath, $destPath); @@ -122,11 +125,15 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $embeddedFiles[$proFilename] = file_get_contents($destPath); $this->embedBackground($service, $embeddedFiles); - $playlistItems[] = [ + $playlistItem = [ 'type' => 'presentation', 'name' => $song->title, 'path' => $proFilename, ]; + if ($selectedArr?->name !== null) { + $playlistItem['arrangement_name'] = $selectedArr->name; + } + $playlistItems[] = $playlistItem; $realContentEmitted = true; } else { @@ -243,7 +250,12 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) $matchedSongs = $service->serviceSongs() ->whereNotNull('song_id') ->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(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); @@ -278,7 +290,10 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) 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'; $destPath = $tempDir.'/'.$proFilename; rename($proPath, $destPath); @@ -286,11 +301,15 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) $embeddedFiles[$proFilename] = file_get_contents($destPath); $this->embedBackground($service, $embeddedFiles); - $playlistItems[] = [ + $playlistItem = [ 'type' => 'presentation', 'name' => $song->title, 'path' => $proFilename, ]; + if ($selectedArr?->name !== null) { + $playlistItem['arrangement_name'] = $selectedArr->name; + } + $playlistItems[] = $playlistItem; } $this->addSlidePresentation( diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 23e3359..6bd45ee 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -45,6 +45,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string 'slides', 'serviceSong.song.arrangements.arrangementSections.section.slides', 'serviceSong.song.arrangements.arrangementSections.section.label', + 'serviceSong.arrangement.arrangementSections.section.slides', + 'serviceSong.arrangement.arrangementSections.section.label', ]); $title = $agendaItem->title ?: 'Ablauf-Element'; @@ -56,7 +58,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string 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'; $songMediaFiles = []; diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index 73e9e0f..be679c3 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -4,7 +4,9 @@ use App\Models\Service; use App\Models\Song; +use App\Models\SongArrangement; use ProPresenter\Parser\ProFileGenerator; +use ProPresenter\Parser\ProFileWriter; class ProExportService { @@ -13,91 +15,72 @@ public function __construct( 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'; - ProFileGenerator::generateAndWrite( - $tempPath, - $song->title, - $this->buildGroups($song, $service), - $this->buildArrangements($song), - $this->buildCcliMetadata($song), - ); + $parserSong = $this->generateParserSong($song, $service, $selected); + ProFileWriter::write($parserSong, $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']); - return ProFileGenerator::generate( + $parserSong = ProFileGenerator::generate( $song->title, - $this->buildGroups($song, $service), + $this->buildGroups($song, $service, $selected), $this->buildArrangements($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 []; } - $defaultArr->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label'); - - $groups = []; - $seenSectionIds = []; $background = $this->backgroundData($service); - // Pre-compute the total slide count across the whole song (same filtering as the loop below) - // so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide. + // Ordered, de-duplicated sections of the SELECTED arrangement (its play order). + $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; - $seenForCount = []; - 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; + foreach ($selectedSections as $section) { $totalSlidesInSong += $section->slides->count(); } + $groups = []; $globalSlideIndex = 0; - foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { - $section = $arrangementSection->section; - $label = $section?->label; - - if ($section === null || $label === null) { - continue; - } - - if (in_array($section->id, $seenSectionIds, true)) { - continue; - } - $seenSectionIds[] = $section->id; - + // 1) Emit the selected arrangement's sections first, with position-aware macros. + foreach ($selectedSections as $section) { + $label = $section->label; $slides = []; - $sectionSlides = $section->slides->sortBy('order')->values(); - foreach ($sectionSlides as $slide) { - $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; - } + foreach ($section->slides->sortBy('order')->values() as $slide) { + $slideData = $this->baseSlideData($slide, $background); if ($service !== null) { $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; } + /** + * 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 + */ + 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 $excludeSectionIds + * @return array + */ + 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 { if ($service === null) { diff --git a/tests/Feature/ArrangementSelectionExportTest.php b/tests/Feature/ArrangementSelectionExportTest.php new file mode 100644 index 0000000..3446f55 --- /dev/null +++ b/tests/Feature/ArrangementSelectionExportTest.php @@ -0,0 +1,231 @@ + $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); + } +}