diff --git a/.sisyphus/notepads/keyvisual-background-nametag/learnings.md b/.sisyphus/notepads/keyvisual-background-nametag/learnings.md index 18957c5..3f0ed81 100644 --- a/.sisyphus/notepads/keyvisual-background-nametag/learnings.md +++ b/.sisyphus/notepads/keyvisual-background-nametag/learnings.md @@ -95,3 +95,16 @@ ## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides - Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`. - Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged. - Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`. + +## [2026-05-31] Task: T10 — NameTagSlideBuilder +- New pure service `App\Services\NameTagSlideBuilder` builds ephemeral slideData only; it does not persist slides. +- `build()` gates solely on non-empty `Setting::get('namenseinblender_macro_name')`; no configured macro name returns `null` so callers skip nametag slides. +- Text contract is plain parser text string with exactly two lines: `$name."\n".$title`; convenience methods use `Moderation` and `Predigt`. +- Parser-facing macro uses camelCase keys: `name`, `uuid`, `collectionName`, `collectionUuid`; collection name defaults to `--MAIN--` via `Setting::get('namenseinblender_macro_collection_name', '--MAIN--')`. +- Tests: `tests/Feature/NameTagSlideBuilderTest.php` covers no-macro null, configured macro/text shape, and moderator/preacher titles. Full suite green: `ddev exec php artisan test` → 544 passed / 2703 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-10-nametag-macro.txt`, `.sisyphus/evidence/task-10-no-macro.txt`. + +## Task 11: Sermon sequence + moderator injection +- Pint auto-removes same-namespace `use App\Services\X` imports (NameTagResolver/NameTagSlideBuilder are already in App\Services); rely on `app(Class::class)` resolution instead of importing. +- Sermon item WITH slides now ALWAYS gets a Keyvisual-Predigt .pro prepended (macro-independent), then Predigername nametag (only if macro set), then sermon slides. This changed KeyVisualFallbackTest expectation from 1 to 2 embedded .pro files. +- Moderator nametag injected as FIRST playlist entry for the first is_before_event=false agenda item (only if macro set). +- PlaylistArchive::getEntries() + entry->getName() is the way to assert playlist ORDER in tests. diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index a9ab710..58ac39e 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -61,7 +61,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $embeddedFiles = []; $skippedUnmatched = 0; + $moderatorSlideData = $this->buildModeratorSlideData($service); + $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; + foreach ($agendaItems as $item) { + if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { + $this->writeProAndEmbed( + 'Moderator', + $moderatorSlideData, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + } + if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) { $patterns = array_map('trim', explode(',', $announcementPatterns)); $matcher = app(AgendaMatcherService::class); @@ -112,6 +125,11 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda } if ($item->slides->isNotEmpty()) { + if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') { + $this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt'); + $this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles); + } + $label = $item->title ?: 'Folien'; $this->addSlidesFromCollection( $item->slides, @@ -447,6 +465,58 @@ protected function writeProFile(string $path, string $name, array $groups, array ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); } + private function buildModeratorSlideData(Service $service): ?array + { + $name = app(NameTagResolver::class)->moderatorFor($service); + if ($name === null) { + return null; + } + + return app(NameTagSlideBuilder::class)->buildModeratorSlide($name); + } + + private function buildPreacherSlideData(Service $service): ?array + { + $name = app(NameTagResolver::class)->preacherFor($service); + if ($name === null) { + return null; + } + + return app(NameTagSlideBuilder::class)->buildPreacherSlide($name); + } + + private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void + { + $kvData = $this->keyVisualData($service); + if ($kvData === null) { + return; + } + + $slideData = ['imageOnly' => true, 'background' => $kvData]; + $this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles); + } + + private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void + { + $slideData = $this->buildPreacherSlideData($service); + if ($slideData === null) { + return; + } + + $this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles); + } + + private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void + { + $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]]; + $arrangements = [['name' => 'normal', 'groupNames' => [$name]]]; + $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro'; + $path = $tempDir.'/'.$filename; + $this->writeProFile($path, $name, $groups, $arrangements); + $embeddedFiles[$filename] = file_get_contents($path); + $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; + } + private function countSongLabels(\App\Models\Song $song): int { return $song->arrangements() diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php index fba0462..8af5cef 100644 --- a/tests/Feature/KeyVisualFallbackTest.php +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -97,7 +97,7 @@ public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void $this->cleanupTempDir($result['temp_dir']); } - public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void + public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_sequence(): void { Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); @@ -106,6 +106,8 @@ public function test_agenda_item_with_uploaded_slides_exports_normally_without_e 'title' => 'Slides Service', 'date' => now(), 'key_visual_filename' => 'slides/keyvisual.jpg', + 'preacher_name' => 'Pastor Paul', + 'preacher_name_override' => null, ]); $agendaItem = ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, @@ -128,9 +130,16 @@ public function test_agenda_item_with_uploaded_slides_exports_normally_without_e $playlist = ProPlaylistReader::read($result['path']); $sermonSong = $playlist->getEmbeddedSong('Predigt.pro'); - $this->assertCount(1, $playlist->getEmbeddedProFiles()); + $this->assertCount(2, $playlist->getEmbeddedProFiles()); $this->assertNotNull($sermonSong); + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + $this->assertContains('Keyvisual-Predigt', $names); + $this->assertLessThan( + array_search('Predigt', $names, true), + array_search('Keyvisual-Predigt', $names, true), + ); + $slides = $this->allParserSlides($sermonSong); $this->assertCount(1, $slides); $this->assertFalse($slides[0]->hasBackgroundMedia()); diff --git a/tests/Feature/PlaylistSequenceTest.php b/tests/Feature/PlaylistSequenceTest.php new file mode 100644 index 0000000..4fd111e --- /dev/null +++ b/tests/Feature/PlaylistSequenceTest.php @@ -0,0 +1,188 @@ +put('slides/keyvisual.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); + + $service = Service::factory()->create([ + 'title' => 'Predigt Service', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'moderator_name' => null, + 'preacher_name' => 'Pastor Paul', + 'preacher_name_override' => null, + ]); + + $agendaItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem->id, + 'type' => 'sermon', + 'original_filename' => 'sermon.jpg', + 'stored_filename' => 'slides/sermon.jpg', + 'sort_order' => 0, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $kvIndex = array_search('Keyvisual-Predigt', $names, true); + $nameTagIndex = array_search('Predigername', $names, true); + $sermonIndex = array_search('Predigt', $names, true); + + $this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing'); + $this->assertNotFalse($nameTagIndex, 'Predigername entry missing'); + $this->assertNotFalse($sermonIndex, 'Predigt entry missing'); + + $this->assertLessThan($nameTagIndex, $kvIndex, 'Keyvisual must come before preacher nametag'); + $this->assertLessThan($sermonIndex, $nameTagIndex, 'Preacher nametag must come before sermon slides'); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_moderator_nametag_is_first_for_first_visible_agenda_item(): void + { + Setting::set('namenseinblender_macro_name', 'Namenseinblender'); + Storage::disk('public')->put('slides/info.jpg', 'info-image'); + + $service = Service::factory()->create([ + 'title' => 'Moderator Service', + 'date' => now(), + 'key_visual_filename' => null, + 'moderator_name' => 'Moderator Max', + ]); + + $agendaItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem->id, + 'type' => 'moderation', + 'original_filename' => 'info.jpg', + 'stored_filename' => 'slides/info.jpg', + 'sort_order' => 0, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $this->assertNotEmpty($names); + $this->assertSame('Moderator', $names[0], 'Moderator nametag must be the first playlist entry'); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_without_macro_no_nametags_are_injected(): void + { + Setting::set('namenseinblender_macro_name', ''); + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); + + $service = Service::factory()->create([ + 'title' => 'Ohne Macro', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'moderator_name' => 'Moderator Max', + 'preacher_name' => 'Pastor Paul', + 'preacher_name_override' => null, + ]); + + $agendaItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $agendaItem->id, + 'type' => 'sermon', + 'original_filename' => 'sermon.jpg', + 'stored_filename' => 'slides/sermon.jpg', + 'sort_order' => 0, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); + + $this->assertNotContains('Moderator', $names); + $this->assertNotContains('Predigername', $names); + + $kvIndex = array_search('Keyvisual-Predigt', $names, true); + $sermonIndex = array_search('Predigt', $names, true); + $this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing'); + $this->assertNotFalse($sermonIndex, 'Predigt entry missing'); + $this->assertLessThan($sermonIndex, $kvIndex, 'Keyvisual must come before sermon slides'); + + $this->cleanupTempDir($result['temp_dir']); + } + + 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); + } +}