feat(export): sermon sequence + moderator injection

This commit is contained in:
Thorsten Bus 2026-05-31 01:58:08 +02:00
parent bb877d16c6
commit 929bda2018
4 changed files with 282 additions and 2 deletions

View file

@ -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.

View file

@ -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()

View file

@ -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());

View file

@ -0,0 +1,188 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class PlaylistSequenceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_sermon_sequence_prepends_keyvisual_and_preacher_nametag_before_slides(): void
{
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Storage::disk('public')->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);
}
}