From b40c371edc48f4dd1988626393333c1781329f56 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 2 Mar 2026 23:02:30 +0100 Subject: [PATCH] feat(export): embed slide blocks in playlist and add roundtrip test Add information, moderation, and sermon slide presentations as .pro files in the generated .proplaylist bundle. Each block queries slides by type/service, converts stored images, and generates a ProPresenter presentation via ProFileGenerator. Add test_download_pro_roundtrip_preserves_content that imports a .pro file, exports it, re-reads with the parser, and asserts song name, groups, slides, translations, arrangements, and CCLI metadata survive the round-trip. --- app/Services/PlaylistExportService.php | 142 +++++++++++++++++++++++-- tests/Feature/ProFileExportTest.php | 109 ++++++++++++++++++- 2 files changed, 240 insertions(+), 11 deletions(-) diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index a502211..6eff673 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\Service; -use App\Models\ServiceSong; +use App\Models\Slide; +use Illuminate\Support\Facades\Storage; +use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProPlaylistGenerator; class PlaylistExportService @@ -22,24 +24,34 @@ public function generatePlaylist(Service $service): array $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedEmpty = 0; - $exportService = new ProExportService(); - $tempDir = sys_get_temp_dir() . '/playlist-export-' . uniqid(); + $exportService = new ProExportService; + $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid(); mkdir($tempDir, 0755, true); $playlistItems = []; $embeddedFiles = []; + $this->addSlidePresentation( + 'information', + 'Informationen', + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + foreach ($matchedSongs as $serviceSong) { $song = $serviceSong->song; if (! $song || $song->groups()->count() === 0) { $skippedEmpty++; + continue; } $proPath = $exportService->generateProFile($song); - $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title) . '.pro'; - $destPath = $tempDir . '/' . $proFilename; + $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; + $destPath = $tempDir.'/'.$proFilename; rename($proPath, $destPath); $embeddedFiles[$proFilename] = $destPath; @@ -51,15 +63,33 @@ public function generatePlaylist(Service $service): array ]; } + $this->addSlidePresentation( + 'moderation', + 'Moderation', + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + + $this->addSlidePresentation( + 'sermon', + 'Predigt', + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + if (empty($playlistItems)) { $this->deleteDirectory($tempDir); throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); - $playlistName = $service->title . ' - ' . $dateFormatted; - $outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title) . '_' . $dateFormatted . '.proplaylist'; - $outputPath = $tempDir . '/' . $outputFilename; + $playlistName = $service->title.' - '.$dateFormatted; + $outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist'; + $outputPath = $tempDir.'/'.$outputFilename; ProPlaylistGenerator::generateAndWrite($outputPath, $playlistName, $playlistItems, $embeddedFiles); @@ -71,6 +101,100 @@ public function generatePlaylist(Service $service): array ]; } + private function addSlidePresentation( + string $type, + string $label, + Service $service, + string $tempDir, + array &$playlistItems, + array &$embeddedFiles, + ): void { + $query = Slide::where('type', $type) + ->whereNull('deleted_at'); + + if ($type === 'information') { + $query->where(function ($q) use ($service) { + $q->whereNull('service_id') + ->orWhere('service_id', $service->id); + }); + + if ($service->date) { + $query->where(function ($q) use ($service) { + $q->whereNull('expire_date') + ->orWhereDate('expire_date', '>=', $service->date); + })->whereDate('uploaded_at', '<=', $service->date); + } + } else { + $query->where('service_id', $service->id); + } + + $slides = $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get(); + + if ($slides->isEmpty()) { + return; + } + + $slideDataList = []; + $imageFiles = []; + + foreach ($slides as $index => $slide) { + $storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename); + + if (! file_exists($storedPath)) { + continue; + } + + $imageFilename = $type.'_'.($index + 1).'_'.$slide->stored_filename; + $destPath = $tempDir.'/'.$imageFilename; + copy($storedPath, $destPath); + + $imageFiles[$imageFilename] = $destPath; + + $slideDataList[] = [ + 'text' => '', + 'media' => $imageFilename, + 'format' => 'JPG', + 'label' => $slide->original_filename, + ]; + } + + if (empty($slideDataList)) { + return; + } + + $groups = [ + [ + 'name' => $label, + 'color' => [0.5, 0.5, 0.5, 1.0], + 'slides' => $slideDataList, + ], + ]; + + $arrangements = [ + [ + 'name' => 'Standard', + 'groupNames' => [$label], + ], + ]; + + $proFilename = $label.'.pro'; + $proPath = $tempDir.'/'.$proFilename; + + ProFileGenerator::generateAndWrite($proPath, $label, $groups, $arrangements); + + foreach ($imageFiles as $filename => $path) { + $embeddedFiles[$filename] = $path; + } + + $embeddedFiles[$proFilename] = $proPath; + + $playlistItems[] = [ + 'type' => 'presentation', + 'name' => $label, + 'path' => $proFilename, + ]; + } + private function deleteDirectory(string $dir): void { if (! is_dir($dir)) { @@ -83,7 +207,7 @@ private function deleteDirectory(string $dir): void continue; } - $path = $dir . '/' . $item; + $path = $dir.'/'.$item; is_dir($path) ? $this->deleteDirectory($path) : unlink($path); } rmdir($dir); diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php index 34b8dcf..02d9105 100644 --- a/tests/Feature/ProFileExportTest.php +++ b/tests/Feature/ProFileExportTest.php @@ -3,8 +3,6 @@ namespace Tests\Feature; use App\Models\Song; -use App\Models\SongGroup; -use App\Models\SongSlide; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -90,6 +88,113 @@ public function test_download_pro_roundtrip_import_export(): void $exportResponse->assertOk(); } + public function test_download_pro_roundtrip_preserves_content(): void + { + $user = User::factory()->create(); + + // 1. Import the reference .pro file + $sourcePath = base_path('../propresenter-work/ref/Test.pro'); + $file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true); + + $importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]); + $importResponse->assertOk(); + + $songId = $importResponse->json('songs.0.id'); + $originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId); + $this->assertNotNull($originalSong); + + // Snapshot original data + $originalGroups = $originalSong->groups->sortBy('order')->values(); + $originalArrangements = $originalSong->arrangements; + + // 2. Export as .pro + $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); + $exportResponse->assertOk(); + + // Save exported content to temp file — BinaryFileResponse delivers a real file + $tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro'; + /** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */ + $baseResponse = $exportResponse->baseResponse; + copy($baseResponse->getFile()->getPathname(), $tempPath); + + // 3. Re-import the exported file as a new song (different ccli to avoid upsert) + // Use the ProPresenter parser directly to read and verify + $reImported = \ProPresenter\Parser\ProFileReader::read($tempPath); + @unlink($tempPath); + + // 4. Assert song name + $this->assertSame($originalSong->title, $reImported->getName()); + + // 5. Assert groups match (same names, same order) + $reImportedGroups = $reImported->getGroups(); + $this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch'); + + foreach ($originalGroups as $index => $originalGroup) { + $reImportedGroup = $reImportedGroups[$index]; + $this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}"); + + // Assert slides within group + $originalSlides = $originalGroup->slides->sortBy('order')->values(); + $reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup); + $this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'"); + + foreach ($originalSlides as $slideIndex => $originalSlide) { + $reImportedSlide = $reImportedSlides[$slideIndex]; + + $this->assertSame( + $originalSlide->text_content, + $reImportedSlide->getPlainText(), + "Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}" + ); + + // Assert translation if present + if ($originalSlide->text_content_translated) { + $this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}"); + $this->assertSame( + $originalSlide->text_content_translated, + $reImportedSlide->getTranslation()?->getPlainText(), + "Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}" + ); + } + } + } + + // 6. Assert arrangements match (same names, same group order) + $reImportedArrangements = $reImported->getArrangements(); + $this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch'); + + foreach ($originalArrangements as $index => $originalArrangement) { + $reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name); + $this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import"); + + $originalGroupNames = $originalArrangement->arrangementGroups + ->sortBy('order') + ->map(fn ($ag) => $ag->group?->name) + ->filter() + ->values() + ->toArray(); + + $reImportedGroupNames = array_map( + fn ($group) => $group->getName(), + $reImported->getGroupsForArrangement($reImportedArrangement) + ); + + $this->assertSame( + $originalGroupNames, + $reImportedGroupNames, + "Arrangement '{$originalArrangement->name}' group order mismatch" + ); + } + + // 7. Assert CCLI metadata + if ($originalSong->ccli_id) { + $this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber()); + } + if ($originalSong->author) { + $this->assertSame($originalSong->author, $reImported->getCcliAuthor()); + } + } + private function assertStringContains(string $needle, ?string $haystack): void { $this->assertNotNull($haystack);