diff --git a/app/Http/Controllers/ExportProFileController.php b/app/Http/Controllers/ExportProFileController.php index 314b04e..e7cdff7 100644 --- a/app/Http/Controllers/ExportProFileController.php +++ b/app/Http/Controllers/ExportProFileController.php @@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class ExportProFileController extends Controller @@ -26,7 +27,13 @@ public function store(Request $request): JsonResponse $created = []; foreach ($request->file('files') as $file) { - $storedPath = $file->store('export-pro-files/'.$type, 'local'); + // Store under a random basename but keep the REAL client extension. + // Laravel's store() names files by the guessed MIME extension, which + // turns a .probundle (a ZIP) into a .zip and breaks extension-based + // routing at export time. + $extension = strtolower($file->getClientOriginalExtension()); + $basename = Str::random(40).($extension !== '' ? '.'.$extension : ''); + $storedPath = $file->storeAs('export-pro-files/'.$type, $basename, 'local'); $maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0; $record = ExportProFile::create([ diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index bcf2af4..caaf75d 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -10,6 +10,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; +use ProPresenter\Parser\ProFileReader; use ProPresenter\Parser\ProPlaylistGenerator; use ProPresenter\Parser\Zip64Fixer; use ZipArchive; @@ -95,6 +96,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $embeddedFiles, $service, 'information', + 'information', ); $announcementInserted = true; } @@ -137,7 +139,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $skippedUnmatched++; } } else { - $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon'; + $partType = $this->backgroundPartTypeForAgendaItem($item); + $isSermon = $partType === 'sermon'; if ($isSermon) { $this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles); @@ -153,7 +156,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $playlistItems, $embeddedFiles, $service, - $this->backgroundPartTypeForAgendaItem($item), + $partType, + $partType, ); if (count($playlistItems) > $countBefore) { @@ -171,7 +175,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $playlistItems, $embeddedFiles, $service, - $this->backgroundPartTypeForAgendaItem($item), + $partType, + $partType, ); if (count($playlistItems) > $countBefore) { @@ -203,7 +208,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda } if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) { - $keywordItem = $this->embedExportProFile($kf, $embeddedFiles); + $keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings); if ($keywordItem !== null) { $playlistItems[] = $keywordItem; } @@ -223,6 +228,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $prependFiles, $service, 'information', + 'information', ); $playlistItems = array_merge($prependItems, $playlistItems); $embeddedFiles = array_merge($prependFiles, $embeddedFiles); @@ -233,7 +239,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } - $this->injectExportProFiles($playlistItems, $embeddedFiles); + $this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings); $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles); @@ -360,7 +366,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } - $this->injectExportProFiles($playlistItems, $embeddedFiles); + $this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings); $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles); @@ -398,6 +404,7 @@ private function addSlidesFromCollection( array &$embeddedFiles, ?Service $service = null, ?string $backgroundPartType = null, + ?string $macroPartType = null, ): void { $slideDataList = []; $imageFiles = []; @@ -440,6 +447,23 @@ private function addSlidesFromCollection( return; } + if ($service !== null && $macroPartType !== null) { + $total = count($slideDataList); + foreach ($slideDataList as $i => &$slideData) { + $macros = $this->macroResolutionService->macrosForSlide( + $service, + $macroPartType, + ['index' => $i, 'total' => $total, 'label_id' => null], + ); + + if (! empty($macros)) { + // ProPresenter parser currently supports one `macro` entry per slide + $slideData['macro'] = $macros[0]; + } + } + unset($slideData); + } + $groups = [ [ 'name' => $label, @@ -516,6 +540,7 @@ private function addSlidePresentation( $embeddedFiles, $service, $type, + $type, ); } @@ -580,28 +605,30 @@ private function addHeadlineItem( ]; } - private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void + private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, array &$warnings): void { $prefixItems = $this->buildExportProItems( ExportProFile::prefix()->orderBy('order')->get(), $embeddedFiles, + $warnings, ); $postfixItems = $this->buildExportProItems( ExportProFile::postfix()->orderBy('order')->get(), $embeddedFiles, + $warnings, ); $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); } /** @param \Illuminate\Support\Collection $files */ - private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array + private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded, array &$warnings): array { $items = []; foreach ($files as $file) { - $item = $this->embedExportProFile($file, $embedded); + $item = $this->embedExportProFile($file, $embedded, $warnings); if ($item !== null) { $items[] = $item; } @@ -613,52 +640,74 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, arra /** * Read a single export .pro/.probundle file from the 'local' disk, embed its * bytes (and any .probundle media) into $embeddedFiles, and return the matching - * playlist presentation item. Returns null when the file is missing or a - * .probundle has no inner .pro entry. + * playlist presentation item. Returns null (and records a German warning) when + * the file is missing, a .probundle has no inner .pro entry, or the embedded + * .pro parses to zero content slides. + * + * The file type is derived from the ORIGINAL (already-validated) client + * filename, NOT from the stored path: Laravel's store() names uploads by the + * guessed MIME extension, so a .probundle (a ZIP) is persisted as .zip. + * Branching on the stored path would then miss the probundle branch and embed + * the raw ZIP bytes as a broken .pro (ProPresenter shows an empty slide). * * The embedded .pro name uses the uppercased type as marker * (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical. * * @return array{type: string, name: string, path: string}|null */ - private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ?array + private function embedExportProFile(ExportProFile $file, array &$embeddedFiles, array &$warnings): ?array { if (! Storage::disk('local')->exists($file->stored_path)) { return null; } - $ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION)); + $ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION)); $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); $embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro'; $displayName = pathinfo($file->original_name, PATHINFO_FILENAME); - if ($ext === 'probundle') { + // Treat both .probundle and .zip as bundles: a .probundle is a ZIP, and + // mis-stored uploads may end up with a .zip extension. + $isBundle = $ext === 'probundle' || $ext === 'zip'; + $mediaFiles = []; + + if ($isBundle) { $storedAbsPath = Storage::disk('local')->path($file->stored_path); $extracted = $this->extractProBundle($storedAbsPath); if ($extracted === null) { + $warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden."; + return null; } [$proBytes, $mediaFiles] = $extracted; - - foreach ($mediaFiles as $entryName => $entryBytes) { - $mediaName = basename((string) $entryName); - if (! isset($embeddedFiles[$mediaName])) { - $embeddedFiles[$mediaName] = $entryBytes; - } - } - - $embeddedFiles[$embeddedProName] = $proBytes; } else { - $bytes = Storage::disk('local')->get($file->stored_path); - if ($bytes === null) { + $proBytes = Storage::disk('local')->get($file->stored_path); + if ($proBytes === null) { return null; } - - $embeddedFiles[$embeddedProName] = $bytes; } + // Fail safe: a contentless export .pro would render an empty slide in + // ProPresenter. Skip it with a warning when it parses to zero content + // slides. Bytes that cannot be parsed at all (unverifiable) are embedded + // verbatim to preserve the legacy pass-through behaviour. + if ($this->contentSlideCountFromProBytes($proBytes) === 0) { + $warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden."; + + return null; + } + + foreach ($mediaFiles as $entryName => $entryBytes) { + $mediaName = basename((string) $entryName); + if (! isset($embeddedFiles[$mediaName])) { + $embeddedFiles[$mediaName] = $entryBytes; + } + } + + $embeddedFiles[$embeddedProName] = $proBytes; + return [ 'type' => 'presentation', 'name' => $displayName, @@ -666,6 +715,42 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ]; } + /** + * Count content slides across all groups of an embedded .pro payload. Returns + * null when the bytes cannot be parsed as a ProPresenter presentation, so the + * caller can distinguish "verified empty" (0) from "unverifiable" (null). + */ + private function contentSlideCountFromProBytes(string $proBytes): ?int + { + if ($proBytes === '') { + return 0; + } + + $tempPath = tempnam(sys_get_temp_dir(), 'export-pro-verify-'); + if ($tempPath === false) { + return null; + } + + try { + if (@file_put_contents($tempPath, $proBytes) === false) { + return null; + } + + $song = ProFileReader::read($tempPath); + + $count = 0; + foreach ($song->getGroups() as $group) { + $count += count($song->getSlidesForGroup($group)); + } + + return $count; + } catch (\Throwable) { + return null; + } finally { + @unlink($tempPath); + } + } + /** * Extract the inner .pro bytes (verbatim) and media files from a .probundle archive. * diff --git a/tests/Feature/ExportProFileProbundleEmbedTest.php b/tests/Feature/ExportProFileProbundleEmbedTest.php new file mode 100644 index 0000000..6439c48 --- /dev/null +++ b/tests/Feature/ExportProFileProbundleEmbedTest.php @@ -0,0 +1,221 @@ +open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('presentation.pro', $proBytes); + foreach ($mediaFiles as $name => $content) { + $zip->addFromString($name, $content); + } + $zip->close(); + + $bytes = file_get_contents($tmp); + @unlink($tmp); + + return $bytes; + } + + private function createSongWithContent(string $title): Song + { + $song = Song::create([ + 'title' => $title, + 'ccli_id' => fake()->unique()->numerify('#####'), + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + ]); + + $label = Label::firstOrCreate( + ['name' => 'Verse - '.$title], + ['color' => '#2196F3'], + ); + $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]); + $section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]); + + return $song; + } + + private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array + { + $slides = []; + foreach ($parserSong->getGroups() as $group) { + foreach ($parserSong->getSlidesForGroup($group) as $slide) { + $slides[] = $slide; + } + } + + return $slides; + } + + 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); + } + + public function test_probundle_mis_stored_as_zip_wird_mit_folien_injiziert(): void + { + $service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]); + + $song = $this->createSongWithContent('Lied'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Lied', + 'order' => 1, + ]); + + // Real inner .pro with a group of 2 content slides. + $proBytes = $this->realProBytes('intro', [ + ['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => [ + ['text' => 'Folie eins'], + ['text' => 'Folie zwei'], + ]], + ], [['name' => 'normal', 'groupNames' => ['Verse']]]); + $bundleBytes = $this->probundleBytes($proBytes, ['hintergrund.jpg' => 'jpg-bytes']); + + // Reproduce the mis-stored state: original name .probundle, stored path .zip. + $storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip'; + Storage::disk('local')->put($storedPath, $bundleBytes); + + ExportProFile::create([ + 'type' => 'prefix', + 'original_name' => 'intro.probundle', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries()); + $this->assertContains('intro', $entryNames, 'Prefix bundle presentation must be injected'); + + $embeddedSong = $playlist->getEmbeddedSong('PREFIX_1_intro.pro'); + $this->assertNotNull($embeddedSong, 'Inner .pro must be embedded, not the raw ZIP'); + $this->assertCount(2, $this->allParserSlides($embeddedSong), 'Inner .pro must keep its 2 content slides'); + + $embeddedFiles = $playlist->getEmbeddedFiles(); + $this->assertArrayHasKey('hintergrund.jpg', $embeddedFiles, 'Bundle media must be embedded'); + $this->assertSame('jpg-bytes', $embeddedFiles['hintergrund.jpg']); + + $this->assertSame([], $result['warnings'], 'A slide-bearing bundle must not be skipped'); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_probundle_ohne_folien_wird_uebersprungen_mit_warnung(): void + { + $service = Service::factory()->create(['title' => 'Leer Service', 'date' => now()]); + + $song = $this->createSongWithContent('Lied'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Lied', + 'order' => 1, + ]); + + // Real inner .pro that parses fine but has a group with ZERO slides. + $proBytes = $this->realProBytes('leer', [ + ['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => []], + ], [['name' => 'normal', 'groupNames' => ['Verse']]]); + $bundleBytes = $this->probundleBytes($proBytes); + + $storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip'; + Storage::disk('local')->put($storedPath, $bundleBytes); + + ExportProFile::create([ + 'type' => 'prefix', + 'original_name' => 'leer.probundle', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries()); + $this->assertNotContains('leer', $entryNames, 'A contentless bundle must not be injected'); + $this->assertNull($playlist->getEmbeddedSong('PREFIX_1_leer.pro'), 'A contentless bundle must not be embedded'); + + $this->assertContains( + "Export-Datei 'leer.probundle' übersprungen: keine Folien gefunden.", + $result['warnings'], + 'A contentless bundle must be skipped with a German warning', + ); + + // The valid song is still exported. + $this->assertContains('Lied', $entryNames); + + $this->cleanupTempDir($result['temp_dir']); + } +} diff --git a/tests/Feature/PlaylistSermonMacroTest.php b/tests/Feature/PlaylistSermonMacroTest.php new file mode 100644 index 0000000..7300da3 --- /dev/null +++ b/tests/Feature/PlaylistSermonMacroTest.php @@ -0,0 +1,266 @@ +put('slides/sermon-1.jpg', 'sermon-one'); + Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two'); + Storage::disk('public')->put('slides/sermon-3.jpg', 'sermon-three'); + + $macroFirst = Macro::factory()->create(['name' => 'Predigt Erste']); + $macroLast = Macro::factory()->create(['name' => 'Predigt Letzte']); + $macroAll = Macro::factory()->create(['name' => 'Predigt Alle']); + + // Lower `order` wins when several assignments match one slide. + MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroFirst->id, 'position' => 'first_slide', 'order' => 0]); + MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroLast->id, 'position' => 'last_slide', 'order' => 1]); + MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroAll->id, 'position' => 'all_slides', 'order' => 2]); + + $service = Service::factory()->create([ + 'title' => 'Predigt Macro Service', + 'date' => now(), + ]); + + // A song with real (DB-driven) content guarantees the export always has at + // least one content presentation, so it never hits the "Keine Songs mit + // Inhalt zum Exportieren gefunden" guard regardless of test ordering or + // slide-file resolution. `responsible => []` pins the agenda items so the + // factory's random responsible data cannot inject stray name-tag slides. + $song = $this->createSongWithContent('Startlied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Startlied', + 'order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Startlied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + 'responsible' => [], + ]); + + $sermonItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 2, + 'is_before_event' => false, + 'responsible' => [], + ]); + $this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0); + $this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1); + $this->createSermonSlide($service, $sermonItem, 'sermon-3.jpg', 2); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $sermon = $playlist->getEmbeddedSong('Predigt.pro'); + $this->assertNotNull($sermon, 'Embedded sermon .pro missing'); + + $slides = $this->allParserSlides($sermon); + $this->assertCount(3, $slides); + + foreach ($slides as $slide) { + $this->assertTrue($slide->hasMacro(), 'Every sermon slide must carry a macro (all_slides guarantees at least one)'); + } + + // First slide: first_slide wins over all_slides (lower order). + $this->assertSame('Predigt Erste', $slides[0]->getMacroName()); + $this->assertSame($macroFirst->uuid, $slides[0]->getMacroUuid()); + + // Middle slide: only all_slides matches. + $this->assertSame('Predigt Alle', $slides[1]->getMacroName()); + + // Last slide: last_slide wins over all_slides (lower order). + $this->assertSame('Predigt Letzte', $slides[2]->getMacroName()); + $this->assertSame($macroLast->uuid, $slides[2]->getMacroUuid()); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * The legacy (no-agenda) export path emits Information, Moderation and Sermon + * presentations with their own explicit part_type. Each must attach the + * all_slides macro registered for that part_type onto every slide. + */ + public function test_legacy_information_moderation_sermon_slides_carry_macros(): void + { + Storage::disk('public')->put('slides/info-1.jpg', 'info-one'); + Storage::disk('public')->put('slides/info-2.jpg', 'info-two'); + Storage::disk('public')->put('slides/mod-1.jpg', 'mod-one'); + Storage::disk('public')->put('slides/mod-2.jpg', 'mod-two'); + Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one'); + Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two'); + + $infoMacro = Macro::factory()->create(['name' => 'Info Macro']); + $modMacro = Macro::factory()->create(['name' => 'Moderation Macro']); + $sermonMacro = Macro::factory()->create(['name' => 'Predigt Macro']); + + MacroAssignment::create(['part_type' => 'information', 'macro_id' => $infoMacro->id, 'position' => 'all_slides', 'order' => 0]); + MacroAssignment::create(['part_type' => 'moderation', 'macro_id' => $modMacro->id, 'position' => 'all_slides', 'order' => 0]); + MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $sermonMacro->id, 'position' => 'all_slides', 'order' => 0]); + + // No agenda items => the export uses the legacy block-ordered path. + $service = Service::factory()->create([ + 'title' => 'Legacy Macro Service', + 'date' => now(), + ]); + + $this->createInfoSlide('info-1.jpg', 0); + $this->createInfoSlide('info-2.jpg', 1); + $this->createServiceSlide($service, 'moderation', 'mod-1.jpg', 0); + $this->createServiceSlide($service, 'moderation', 'mod-2.jpg', 1); + $this->createServiceSlide($service, 'sermon', 'sermon-1.jpg', 0); + $this->createServiceSlide($service, 'sermon', 'sermon-2.jpg', 1); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Informationen.pro'), 'Info Macro'); + $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Moderation.pro'), 'Moderation Macro'); + $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Predigt.pro'), 'Predigt Macro'); + + $this->cleanupTempDir($result['temp_dir']); + } + + private function assertAllSlidesHaveMacro(?\ProPresenter\Parser\Song $song, string $macroName): void + { + $this->assertNotNull($song, "Embedded .pro for macro '{$macroName}' missing"); + + $slides = $this->allParserSlides($song); + $this->assertCount(2, $slides); + + foreach ($slides as $slide) { + $this->assertTrue($slide->hasMacro(), "Slide for '{$macroName}' must carry a macro"); + $this->assertSame($macroName, $slide->getMacroName()); + } + } + + private function createSermonSlide(Service $service, ServiceAgendaItem $agendaItem, string $filename, int $sortOrder): Slide + { + return Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem->id, + 'type' => 'sermon', + 'original_filename' => $filename, + 'stored_filename' => 'slides/'.$filename, + 'sort_order' => $sortOrder, + ]); + } + + private function createInfoSlide(string $filename, int $sortOrder): Slide + { + return Slide::factory()->create([ + 'service_id' => null, + 'type' => 'information', + 'original_filename' => $filename, + 'stored_filename' => 'slides/'.$filename, + 'sort_order' => $sortOrder, + 'expire_date' => null, + 'uploaded_at' => now()->subDay(), + ]); + } + + private function createServiceSlide(Service $service, string $type, string $filename, int $sortOrder): Slide + { + return Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => $type, + 'original_filename' => $filename, + 'stored_filename' => 'slides/'.$filename, + 'sort_order' => $sortOrder, + ]); + } + + private function createSongWithContent(string $title): \App\Models\Song + { + $song = \App\Models\Song::create([ + 'title' => $title, + 'ccli_id' => fake()->unique()->numerify('#####'), + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + ]); + + $label = \App\Models\Label::firstOrCreate( + ['name' => 'Verse - '.$title], + ['color' => '#2196F3'], + ); + $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]); + $section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]); + + return $song; + } + + private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array + { + $slides = []; + + foreach ($parserSong->getGroups() as $group) { + foreach ($parserSong->getSlidesForGroup($group) as $slide) { + $slides[] = $slide; + } + } + + return $slides; + } + + 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); + } +}