fix(export): preserve probundle extension and attach slide macros

- store() persisted uploads under Laravel's MIME-guessed extension,
  turning a .probundle (a ZIP) into .zip; embedExportProFile then
  branched on the stored path, missed the bundle branch and embedded
  the raw ZIP as a broken .pro (ProPresenter showed an empty slide).
  Store with the real client extension and derive the bundle type from
  the original filename, treating .zip as a bundle for legacy uploads.
- Skip export .pro/.probundle files that parse to zero content slides,
  recording a German warning instead of injecting an empty slide.
- Attach position macros (first_slide/last_slide/all_slides) per slide
  for information/moderation/sermon parts in the agenda and legacy paths.
- Cover both with ExportProFileProbundleEmbedTest and PlaylistSermonMacroTest.
This commit is contained in:
Thorsten Bus 2026-07-06 11:19:56 +02:00
parent f2c0c05200
commit fdabaadb89
4 changed files with 607 additions and 28 deletions

View file

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

View file

@ -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<int, ExportProFile> $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,34 +640,64 @@ 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;
} else {
$proBytes = Storage::disk('local')->get($file->stored_path);
if ($proBytes === null) {
return null;
}
}
// 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);
@ -650,14 +707,6 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles):
}
$embeddedFiles[$embeddedProName] = $proBytes;
} else {
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
return null;
}
$embeddedFiles[$embeddedProName] = $bytes;
}
return [
'type' => 'presentation',
@ -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.
*

View file

@ -0,0 +1,221 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
use ZipArchive;
/**
* Regression cover for the "empty keyword/prefix show" bug: a .probundle uploaded
* as a keyword/prefix/postfix export is stored by Laravel under a MIME-guessed
* .zip extension. Branching on the stored path then misses the probundle branch
* and embeds the raw ZIP bytes as a broken .pro (ProPresenter shows 0 slides).
*
* These tests use REAL, slide-bearing .pro bytes (generated by the parser) wrapped
* in a real ZIP dummy string bytes are exactly what hid the original bug.
*/
final class ExportProFileProbundleEmbedTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
/** Build real, parseable .pro bytes with the given groups/arrangements. */
private function realProBytes(string $name, array $groups, array $arrangements): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-pro-').'.pro';
ProFileGenerator::generateAndWrite($tmp, $name, $groups, $arrangements);
$bytes = file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
/** Wrap real .pro bytes (+ optional media) into a real .probundle ZIP, return the archive bytes. */
private function probundleBytes(string $proBytes, array $mediaFiles = []): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-bundle-').'.probundle';
$zip = new ZipArchive;
$zip->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']);
}
}

View file

@ -0,0 +1,266 @@
<?php
namespace Tests\Feature;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
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 PlaylistSermonMacroTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
/**
* Agenda-based sermon export must attach position macros (first_slide /
* last_slide / all_slides) for part_type='sermon' onto the sermon slides.
* The parser stores only one macro per slide (the app keeps $macros[0]); the
* lowest `order` among the matching assignments wins, so first/last override
* all_slides on the first/last slide while the middle slide keeps all_slides.
*/
public function test_playlist_sermon_slides_carry_position_macros(): void
{
Storage::disk('public')->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);
}
}