feat(export): keyvisual fallback slides
This commit is contained in:
parent
196657b52b
commit
a19c967594
15
.sisyphus/evidence/task-9-fallback.txt
Normal file
15
.sisyphus/evidence/task-9-fallback.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Task T9 keyvisual fallback evidence
|
||||
|
||||
Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides.
|
||||
|
||||
Result:
|
||||
- PlaylistExportService creates an embedded .pro for the agenda item title.
|
||||
- The generated .pro contains exactly one image-only slide with background media.
|
||||
- Background media URL is Storage::disk('public')->path($keyvisual).
|
||||
- Slide rows are not created; Slide::count() remains unchanged.
|
||||
|
||||
Verification:
|
||||
- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item.
|
||||
- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions.
|
||||
- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions.
|
||||
- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files.
|
||||
16
.sisyphus/evidence/task-9-no-keyvisual.txt
Normal file
16
.sisyphus/evidence/task-9-no-keyvisual.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
Task T9 no-keyvisual evidence
|
||||
|
||||
Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides.
|
||||
|
||||
Result:
|
||||
- Empty non-song agenda item adds no playlist entry and no .pro file.
|
||||
- Song agenda items still export through the normal song .pro path.
|
||||
- Uploaded agenda slides still export normally; no keyvisual slide is prepended.
|
||||
- Song agenda items never receive keyvisual fallback slides.
|
||||
|
||||
Verification:
|
||||
- `tests/Feature/KeyVisualFallbackTest.php` covers:
|
||||
- no keyvisual → no empty-item .pro entry,
|
||||
- song item → only song .pro,
|
||||
- uploaded slides → exactly the uploaded slide presentation and no fallback background.
|
||||
- Full suite after implementation: 541 passed, 2699 assertions.
|
||||
|
|
@ -88,3 +88,10 @@ ## [2026-05-31] Task: T8 — Export background layer
|
|||
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
|
||||
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
|
||||
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
|
||||
|
||||
## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
|
||||
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
|
||||
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
|
||||
- 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`.
|
||||
|
|
|
|||
|
|
@ -123,6 +123,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$service,
|
||||
$this->backgroundPartTypeForAgendaItem($item),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isNameTagAgendaItem($item)) {
|
||||
$this->addKeyVisualFallbackPresentation(
|
||||
$item,
|
||||
$service,
|
||||
$tempDir,
|
||||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,6 +394,54 @@ private function addSlidePresentation(
|
|||
);
|
||||
}
|
||||
|
||||
private function addKeyVisualFallbackPresentation(
|
||||
ServiceAgendaItem $item,
|
||||
Service $service,
|
||||
string $tempDir,
|
||||
array &$playlistItems,
|
||||
array &$embeddedFiles,
|
||||
): void {
|
||||
$background = $this->keyVisualData($service);
|
||||
|
||||
if ($background === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $item->title ?: 'Keyvisual';
|
||||
$groups = [
|
||||
[
|
||||
'name' => 'Keyvisual',
|
||||
'color' => [0, 0, 0, 1],
|
||||
'slides' => [
|
||||
[
|
||||
'imageOnly' => true,
|
||||
'background' => $background,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
$arrangements = [
|
||||
[
|
||||
'name' => 'normal',
|
||||
'groupNames' => ['Keyvisual'],
|
||||
],
|
||||
];
|
||||
|
||||
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
|
||||
$proFilename = $safeLabel.'.pro';
|
||||
$proPath = $tempDir.'/'.$proFilename;
|
||||
|
||||
$this->writeProFile($proPath, $label, $groups, $arrangements);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($proPath);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => $label,
|
||||
'path' => $proFilename,
|
||||
];
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
{
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||
|
|
@ -415,6 +475,22 @@ private function backgroundData(?Service $service): ?array
|
|||
];
|
||||
}
|
||||
|
||||
private function keyVisualData(Service $service): ?array
|
||||
{
|
||||
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
|
||||
|
||||
if ($keyVisual === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => Storage::disk('public')->path($keyVisual),
|
||||
'format' => 'JPG',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||
{
|
||||
if ($background === null) {
|
||||
|
|
@ -446,6 +522,17 @@ private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): strin
|
|||
: 'agenda_item';
|
||||
}
|
||||
|
||||
private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
|
||||
{
|
||||
$title = mb_strtolower($item->title ?? '');
|
||||
$type = mb_strtolower($item->type ?? '');
|
||||
|
||||
return str_contains($title, 'nametag')
|
||||
|| str_contains($title, 'namenseinblender')
|
||||
|| str_contains($type, 'nametag')
|
||||
|| str_contains($type, 'namenseinblender');
|
||||
}
|
||||
|
||||
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||
{
|
||||
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
||||
|
|
|
|||
237
tests/Feature/KeyVisualFallbackTest.php
Normal file
237
tests/Feature/KeyVisualFallbackTest.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Slide;
|
||||
use App\Models\Song;
|
||||
use App\Services\PlaylistExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class KeyVisualFallbackTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
}
|
||||
|
||||
public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Keyvisual Service',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||
]);
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'service_song_id' => null,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
|
||||
$slideCountBefore = Slide::count();
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
|
||||
|
||||
$this->assertCount(1, $embeddedProFiles);
|
||||
$this->assertNotNull($fallbackSong);
|
||||
$this->assertSame($slideCountBefore, Slide::count());
|
||||
|
||||
$slides = $this->allParserSlides($fallbackSong);
|
||||
$this->assertCount(1, $slides);
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||
$this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $slides[0]->getBackgroundMediaUrl());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Song Service',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||
]);
|
||||
$song = $this->createSongWithContent('Nur ein Lied');
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Nur ein Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Nur ein Lied',
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||
$this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro'));
|
||||
$this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro'));
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Slides Service',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||
]);
|
||||
$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']);
|
||||
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
||||
|
||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||
$this->assertNotNull($sermonSong);
|
||||
|
||||
$slides = $this->allParserSlides($sermonSong);
|
||||
$this->assertCount(1, $slides);
|
||||
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Ohne Keyvisual',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => null,
|
||||
]);
|
||||
$song = $this->createSongWithContent('Vorhandenes Lied');
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Vorhandenes Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung ohne Folien',
|
||||
'service_song_id' => null,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Vorhandenes Lied',
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'sort_order' => 2,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
|
||||
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
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 1 - '.$title],
|
||||
['color' => '#2196F3'],
|
||||
);
|
||||
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
|
||||
|
||||
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||
$arrangement->arrangementLabels()->create(['label_id' => $label->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue