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:
parent
f2c0c05200
commit
fdabaadb89
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ExportProFileController extends Controller
|
class ExportProFileController extends Controller
|
||||||
|
|
@ -26,7 +27,13 @@ public function store(Request $request): JsonResponse
|
||||||
$created = [];
|
$created = [];
|
||||||
|
|
||||||
foreach ($request->file('files') as $file) {
|
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;
|
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
|
||||||
|
|
||||||
$record = ExportProFile::create([
|
$record = ExportProFile::create([
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProFileGenerator;
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
|
use ProPresenter\Parser\ProFileReader;
|
||||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||||
use ProPresenter\Parser\Zip64Fixer;
|
use ProPresenter\Parser\Zip64Fixer;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
@ -95,6 +96,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
'information',
|
'information',
|
||||||
|
'information',
|
||||||
);
|
);
|
||||||
$announcementInserted = true;
|
$announcementInserted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +139,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$skippedUnmatched++;
|
$skippedUnmatched++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
|
$partType = $this->backgroundPartTypeForAgendaItem($item);
|
||||||
|
$isSermon = $partType === 'sermon';
|
||||||
|
|
||||||
if ($isSermon) {
|
if ($isSermon) {
|
||||||
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
|
@ -153,7 +156,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$this->backgroundPartTypeForAgendaItem($item),
|
$partType,
|
||||||
|
$partType,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (count($playlistItems) > $countBefore) {
|
if (count($playlistItems) > $countBefore) {
|
||||||
|
|
@ -171,7 +175,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$this->backgroundPartTypeForAgendaItem($item),
|
$partType,
|
||||||
|
$partType,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (count($playlistItems) > $countBefore) {
|
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))) {
|
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) {
|
if ($keywordItem !== null) {
|
||||||
$playlistItems[] = $keywordItem;
|
$playlistItems[] = $keywordItem;
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +228,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$prependFiles,
|
$prependFiles,
|
||||||
$service,
|
$service,
|
||||||
'information',
|
'information',
|
||||||
|
'information',
|
||||||
);
|
);
|
||||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
$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.');
|
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);
|
$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.');
|
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);
|
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
|
||||||
|
|
@ -398,6 +404,7 @@ private function addSlidesFromCollection(
|
||||||
array &$embeddedFiles,
|
array &$embeddedFiles,
|
||||||
?Service $service = null,
|
?Service $service = null,
|
||||||
?string $backgroundPartType = null,
|
?string $backgroundPartType = null,
|
||||||
|
?string $macroPartType = null,
|
||||||
): void {
|
): void {
|
||||||
$slideDataList = [];
|
$slideDataList = [];
|
||||||
$imageFiles = [];
|
$imageFiles = [];
|
||||||
|
|
@ -440,6 +447,23 @@ private function addSlidesFromCollection(
|
||||||
return;
|
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 = [
|
$groups = [
|
||||||
[
|
[
|
||||||
'name' => $label,
|
'name' => $label,
|
||||||
|
|
@ -516,6 +540,7 @@ private function addSlidePresentation(
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
$service,
|
$service,
|
||||||
$type,
|
$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(
|
$prefixItems = $this->buildExportProItems(
|
||||||
ExportProFile::prefix()->orderBy('order')->get(),
|
ExportProFile::prefix()->orderBy('order')->get(),
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
|
$warnings,
|
||||||
);
|
);
|
||||||
|
|
||||||
$postfixItems = $this->buildExportProItems(
|
$postfixItems = $this->buildExportProItems(
|
||||||
ExportProFile::postfix()->orderBy('order')->get(),
|
ExportProFile::postfix()->orderBy('order')->get(),
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
|
$warnings,
|
||||||
);
|
);
|
||||||
|
|
||||||
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
|
/** @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 = [];
|
$items = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$item = $this->embedExportProFile($file, $embedded);
|
$item = $this->embedExportProFile($file, $embedded, $warnings);
|
||||||
if ($item !== null) {
|
if ($item !== null) {
|
||||||
$items[] = $item;
|
$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
|
* Read a single export .pro/.probundle file from the 'local' disk, embed its
|
||||||
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
|
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
|
||||||
* playlist presentation item. Returns null when the file is missing or a
|
* playlist presentation item. Returns null (and records a German warning) when
|
||||||
* .probundle has no inner .pro entry.
|
* 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
|
* The embedded .pro name uses the uppercased type as marker
|
||||||
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
|
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
|
||||||
*
|
*
|
||||||
* @return array{type: string, name: string, path: string}|null
|
* @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)) {
|
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||||
return null;
|
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));
|
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||||
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
|
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
|
||||||
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
|
$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);
|
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
|
||||||
|
|
||||||
$extracted = $this->extractProBundle($storedAbsPath);
|
$extracted = $this->extractProBundle($storedAbsPath);
|
||||||
if ($extracted === null) {
|
if ($extracted === null) {
|
||||||
|
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$proBytes, $mediaFiles] = $extracted;
|
[$proBytes, $mediaFiles] = $extracted;
|
||||||
|
|
||||||
foreach ($mediaFiles as $entryName => $entryBytes) {
|
|
||||||
$mediaName = basename((string) $entryName);
|
|
||||||
if (! isset($embeddedFiles[$mediaName])) {
|
|
||||||
$embeddedFiles[$mediaName] = $entryBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$embeddedFiles[$embeddedProName] = $proBytes;
|
|
||||||
} else {
|
} else {
|
||||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
$proBytes = Storage::disk('local')->get($file->stored_path);
|
||||||
if ($bytes === null) {
|
if ($proBytes === null) {
|
||||||
return 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 [
|
return [
|
||||||
'type' => 'presentation',
|
'type' => 'presentation',
|
||||||
'name' => $displayName,
|
'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.
|
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
221
tests/Feature/ExportProFileProbundleEmbedTest.php
Normal file
221
tests/Feature/ExportProFileProbundleEmbedTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
266
tests/Feature/PlaylistSermonMacroTest.php
Normal file
266
tests/Feature/PlaylistSermonMacroTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue