feat(export): background layer on song/sermon slides
This commit is contained in:
parent
d2193bb3b2
commit
196657b52b
15
.sisyphus/evidence/task-8-no-background.txt
Normal file
15
.sisyphus/evidence/task-8-no-background.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Task T8 evidence — no background / excluded slide types
|
||||||
|
|
||||||
|
Verified by `Tests\Feature\ProFileExportTest`:
|
||||||
|
- `test_export_ohne_background_enthaelt_keine_background_actions`
|
||||||
|
- Service without resolved background generates successfully
|
||||||
|
- exported song slides contain zero BACKGROUND media actions
|
||||||
|
- `test_information_und_moderation_exports_erhalten_keinen_background`
|
||||||
|
- information bundle gets no BACKGROUND media
|
||||||
|
- moderation bundle gets no BACKGROUND media
|
||||||
|
- `test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen`
|
||||||
|
- final playlist keeps information slides without BACKGROUND media
|
||||||
|
|
||||||
|
Verification commands:
|
||||||
|
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php` → 13 passed, 76 assertions
|
||||||
|
- `ddev exec php artisan test` → 537 passed, 2683 assertions
|
||||||
20
.sisyphus/evidence/task-8-song-background.txt
Normal file
20
.sisyphus/evidence/task-8-song-background.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
Task T8 evidence — song/sermon background layer
|
||||||
|
|
||||||
|
RED:
|
||||||
|
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
|
||||||
|
- Failed as expected before implementation:
|
||||||
|
- song slides had no BACKGROUND media action
|
||||||
|
- slides table had no `cover_mode` column for full-cover detection
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
- `ddev exec php artisan test tests/Feature/ProFileExportTest.php`
|
||||||
|
- 13 passed, 76 assertions
|
||||||
|
- Verified:
|
||||||
|
- every song text slide gets BACKGROUND media when ServiceImageResolver resolves a background
|
||||||
|
- sermon image slides get BACKGROUND media when not full-cover
|
||||||
|
- full-cover sermon image slides (`cover_mode=true`) skip BACKGROUND media
|
||||||
|
- final .proplaylist export preserves the same sermon/full-cover behavior
|
||||||
|
|
||||||
|
Full suite:
|
||||||
|
- `ddev exec ./vendor/bin/pint ... && ddev exec php artisan test`
|
||||||
|
- 537 passed, 2683 assertions
|
||||||
|
|
@ -59,3 +59,32 @@ ## [2026-05-31] Task: T5
|
||||||
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
|
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
|
||||||
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
|
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
|
||||||
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.
|
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.
|
||||||
|
|
||||||
|
## [2026-05-31] Task: T6 — ServiceImageController
|
||||||
|
- New controller `app/Http/Controllers/ServiceImageController.php`: `storeKeyVisual()` + `storeBackground()` both delegate to private `store($request, $service, $column, $settingKey)`.
|
||||||
|
- Validation: `file` => required|file|mimes:jpg,jpeg,png|max:20480 (20MB); `scope` => required|Rule::in(['service','default']). German messages via 2nd arg to `$request->validate([...], [...])`.
|
||||||
|
- Conversion: `app(FileConversionService::class)->convertImageCover($request->file('file'))` → stores `$result['filename']` (slides/{uuid}.jpg) into `key_visual_filename`/`background_filename` via `$service->update([...])`.
|
||||||
|
- scope=default ALSO calls `Setting::set('current_key_visual'|'current_background', $result['filename'])`. scope=service leaves global Setting untouched.
|
||||||
|
- Old file NOT deleted on replace (protects finalized snapshots). Verified by test.
|
||||||
|
- Routes (inside auth group): `POST /services/{service}/key-visual` → `services.key-visual.store`; `POST /services/{service}/background` → `services.background.store`.
|
||||||
|
- Response: `back()->with('success', 'Bild wurde gespeichert.')` (Inertia redirect). Web validation failures redirect 302; tests use `postJson()` to assert the 422 JSON contract.
|
||||||
|
- GOTCHA: after adding controller, route cache + autoload were stale → `ddev exec composer dump-autoload && ddev exec php artisan optimize:clear`. Also the `use` import edit silently didn't stick first time — verify imports after editing routes/web.php.
|
||||||
|
- Test file `tests/Feature/ServiceImageControllerTest.php` (6 tests). Helper `makeImageUpload($name,$w,$h)` GD-based (same pattern as SlideControllerTest's makePngUploadForSlide).
|
||||||
|
- Full suite: 525 passed (519 baseline + 6). Pint clean.
|
||||||
|
- Evidence: `.sisyphus/evidence/task-6-default-upload.txt`, `task-6-invalid-upload.txt`.
|
||||||
|
|
||||||
|
## [2026-05-31] Task: T7 — NameTagResolver
|
||||||
|
- New service `App\Services\NameTagResolver`: `moderatorFor(Service): ?string`, `preacherFor(Service): ?string`.
|
||||||
|
- Moderator resolution: trimmed `services.moderator_name` wins; else first visible agenda item (`is_before_event=false`) ordered by `sort_order`, then `id`; responsible names joined by `', '`; no name => `null`.
|
||||||
|
- Preacher resolution: trimmed `services.preacher_name_override` wins; else trimmed `services.preacher_name`; else first visible non-song sermon agenda item (`service_song_id IS NULL`) responsible names; no name => `null`.
|
||||||
|
- `responsible` JSON findings: model casts to array; sync test proves associative object shape `{name: 'Max Mustermann'}`; expected multi-person shape is list of objects `[{name: 'Anna Müller'}, {name: 'Tom Klein'}]`. Resolver supports both plus string entries and `firstName`/`lastName` or snake_case fallbacks.
|
||||||
|
- Sermon detection method: use `Setting::get('agenda_sermon_matching')` comma patterns through `AgendaMatcherService::matchesAny()` (same matcher used by ServiceController/Service finalization). If no setting is configured, fallback checks title/type for `predigt` or `sermon`.
|
||||||
|
- Tests: `tests/Feature/NameTagResolverTest.php` covers override/fallback/null branches for moderator and preacher. Full suite green: `ddev exec php artisan test` → 532 passed (2659 assertions). Pint clean. Evidence: `.sisyphus/evidence/task-7-moderator.txt`, `.sisyphus/evidence/task-7-preacher.txt`.
|
||||||
|
|
||||||
|
## [2026-05-31] Task: T8 — Export background layer
|
||||||
|
- `ProExportService::buildGroups()` now resolves `ServiceImageResolver::backgroundFor($service)` once per song export and adds `slideData['background']` with `Storage::disk('public')->path(...)`, format JPG, 1920×1080 to every non-full-cover song slide.
|
||||||
|
- Sermon image exports use the same background contract in `PlaylistExportService` and `ProBundleExportService`; information/moderation slide exports explicitly remain without background.
|
||||||
|
- Full-cover detection is persisted with new nullable `slides.cover_mode` (`null` legacy/unknown, `false` contain, `true` cover). `SlideController` stores `$result['fullCover']` from image/ZIP conversions; existing contain conversions store `false`.
|
||||||
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ private function handleImage(
|
||||||
'original_filename' => $file->getClientOriginalName(),
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
'stored_filename' => $result['filename'],
|
'stored_filename' => $result['filename'],
|
||||||
'thumbnail_filename' => $result['thumbnail'],
|
'thumbnail_filename' => $result['thumbnail'],
|
||||||
|
'cover_mode' => $result['fullCover'] ?? null,
|
||||||
'expire_date' => $expireDate,
|
'expire_date' => $expireDate,
|
||||||
'uploader_name' => $uploaderName,
|
'uploader_name' => $uploaderName,
|
||||||
'uploaded_at' => now(),
|
'uploaded_at' => now(),
|
||||||
|
|
@ -260,6 +261,7 @@ private function handleZip(
|
||||||
'original_filename' => $file->getClientOriginalName(),
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
'stored_filename' => $result['filename'],
|
'stored_filename' => $result['filename'],
|
||||||
'thumbnail_filename' => $result['thumbnail'],
|
'thumbnail_filename' => $result['thumbnail'],
|
||||||
|
'cover_mode' => $result['fullCover'] ?? null,
|
||||||
'expire_date' => $expireDate,
|
'expire_date' => $expireDate,
|
||||||
'uploader_name' => $uploaderName,
|
'uploader_name' => $uploaderName,
|
||||||
'uploaded_at' => now(),
|
'uploaded_at' => now(),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class Slide extends Model
|
||||||
'original_filename',
|
'original_filename',
|
||||||
'stored_filename',
|
'stored_filename',
|
||||||
'thumbnail_filename',
|
'thumbnail_filename',
|
||||||
|
'cover_mode',
|
||||||
'expire_date',
|
'expire_date',
|
||||||
'uploader_name',
|
'uploader_name',
|
||||||
'uploaded_at',
|
'uploaded_at',
|
||||||
|
|
@ -30,6 +31,7 @@ protected function casts(): array
|
||||||
return [
|
return [
|
||||||
'expire_date' => 'date',
|
'expire_date' => 'date',
|
||||||
'uploaded_at' => 'datetime',
|
'uploaded_at' => 'datetime',
|
||||||
|
'cover_mode' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
|
$service,
|
||||||
|
'information',
|
||||||
);
|
);
|
||||||
$announcementInserted = true;
|
$announcementInserted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +120,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
|
$service,
|
||||||
|
$this->backgroundPartTypeForAgendaItem($item),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +136,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$prependItems,
|
$prependItems,
|
||||||
$prependFiles,
|
$prependFiles,
|
||||||
|
$service,
|
||||||
|
'information',
|
||||||
);
|
);
|
||||||
$playlistItems = array_merge($prependItems, $playlistItems);
|
$playlistItems = array_merge($prependItems, $playlistItems);
|
||||||
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
|
||||||
|
|
@ -260,9 +266,12 @@ private function addSlidesFromCollection(
|
||||||
string $tempDir,
|
string $tempDir,
|
||||||
array &$playlistItems,
|
array &$playlistItems,
|
||||||
array &$embeddedFiles,
|
array &$embeddedFiles,
|
||||||
|
?Service $service = null,
|
||||||
|
?string $backgroundPartType = null,
|
||||||
): void {
|
): void {
|
||||||
$slideDataList = [];
|
$slideDataList = [];
|
||||||
$imageFiles = [];
|
$imageFiles = [];
|
||||||
|
$background = $this->backgroundData($service);
|
||||||
|
|
||||||
foreach ($slides->values() as $index => $slide) {
|
foreach ($slides->values() as $index => $slide) {
|
||||||
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
||||||
|
|
@ -277,11 +286,17 @@ private function addSlidesFromCollection(
|
||||||
|
|
||||||
$imageFiles[$imageFilename] = file_get_contents($destPath);
|
$imageFiles[$imageFilename] = file_get_contents($destPath);
|
||||||
|
|
||||||
$slideDataList[] = [
|
$singleSlideData = [
|
||||||
'media' => $imageFilename,
|
'media' => $imageFilename,
|
||||||
'format' => 'JPG',
|
'format' => 'JPG',
|
||||||
'label' => $slide->original_filename,
|
'label' => $slide->original_filename,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||||
|
$singleSlideData['background'] = $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slideDataList[] = $singleSlideData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($slideDataList)) {
|
if (empty($slideDataList)) {
|
||||||
|
|
@ -362,6 +377,8 @@ private function addSlidePresentation(
|
||||||
$tempDir,
|
$tempDir,
|
||||||
$playlistItems,
|
$playlistItems,
|
||||||
$embeddedFiles,
|
$embeddedFiles,
|
||||||
|
$service,
|
||||||
|
$type,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,6 +395,57 @@ private function countSongLabels(\App\Models\Song $song): int
|
||||||
->sum('arrangement_labels_count');
|
->sum('arrangement_labels_count');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function backgroundData(?Service $service): ?array
|
||||||
|
{
|
||||||
|
if ($service === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$background = app(ServiceImageResolver::class)->backgroundFor($service);
|
||||||
|
|
||||||
|
if ($background === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => Storage::disk('public')->path($background),
|
||||||
|
'format' => 'JPG',
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||||
|
{
|
||||||
|
if ($background === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slide->cover_mode !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): string
|
||||||
|
{
|
||||||
|
if ($item->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
|
||||||
|
return 'sermon';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sermonPatterns = Setting::get('agenda_sermon_matching');
|
||||||
|
if ($sermonPatterns === null) {
|
||||||
|
return 'agenda_item';
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = array_map('trim', explode(',', $sermonPatterns));
|
||||||
|
|
||||||
|
return app(AgendaMatcherService::class)->matchesAny($item->title, $patterns)
|
||||||
|
? 'sermon'
|
||||||
|
: 'agenda_item';
|
||||||
|
}
|
||||||
|
|
||||||
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
{
|
{
|
||||||
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceAgendaItem;
|
use App\Models\ServiceAgendaItem;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Slide;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use ProPresenter\Parser\PresentationBundle;
|
use ProPresenter\Parser\PresentationBundle;
|
||||||
|
|
@ -17,6 +19,7 @@ class ProBundleExportService
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MacroResolutionService $macroResolutionService,
|
private readonly MacroResolutionService $macroResolutionService,
|
||||||
|
private readonly ServiceImageResolver $imageResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function generateBundle(Service $service, string $blockType): string
|
public function generateBundle(Service $service, string $blockType): string
|
||||||
|
|
@ -32,7 +35,7 @@ public function generateBundle(Service $service, string $blockType): string
|
||||||
|
|
||||||
$groupName = ucfirst($blockType);
|
$groupName = ucfirst($blockType);
|
||||||
|
|
||||||
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType);
|
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
|
|
@ -72,14 +75,26 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return $this->buildBundleFromSlides($slides, $title, $agendaItem->service, 'agenda_item');
|
return $this->buildBundleFromSlides(
|
||||||
|
$slides,
|
||||||
|
$title,
|
||||||
|
$agendaItem->service,
|
||||||
|
'agenda_item',
|
||||||
|
$this->backgroundPartTypeForAgendaItem($agendaItem),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
||||||
private function buildBundleFromSlides($slides, string $groupName, ?Service $service = null, ?string $partType = null): string
|
private function buildBundleFromSlides(
|
||||||
{
|
$slides,
|
||||||
|
string $groupName,
|
||||||
|
?Service $service = null,
|
||||||
|
?string $partType = null,
|
||||||
|
?string $backgroundPartType = null,
|
||||||
|
): string {
|
||||||
$slideData = [];
|
$slideData = [];
|
||||||
$mediaFiles = [];
|
$mediaFiles = [];
|
||||||
|
$background = $this->backgroundData($service);
|
||||||
|
|
||||||
foreach ($slides as $slide) {
|
foreach ($slides as $slide) {
|
||||||
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
||||||
|
|
@ -101,6 +116,10 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
|
||||||
'label' => $slide->original_filename,
|
'label' => $slide->original_filename,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||||
|
$singleSlideData['background'] = $background;
|
||||||
|
}
|
||||||
|
|
||||||
if ($service !== null && $partType !== null) {
|
if ($service !== null && $partType !== null) {
|
||||||
$slideIndex = count($slideData);
|
$slideIndex = count($slideData);
|
||||||
$totalSlides = $slides->count();
|
$totalSlides = $slides->count();
|
||||||
|
|
@ -144,6 +163,57 @@ private function buildBundleFromSlides($slides, string $groupName, ?Service $ser
|
||||||
return $bundlePath;
|
return $bundlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function backgroundData(?Service $service): ?array
|
||||||
|
{
|
||||||
|
if ($service === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$background = $this->imageResolver->backgroundFor($service);
|
||||||
|
|
||||||
|
if ($background === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => Storage::disk('public')->path($background),
|
||||||
|
'format' => 'JPG',
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||||
|
{
|
||||||
|
if ($background === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slide->cover_mode !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $agendaItem): string
|
||||||
|
{
|
||||||
|
if ($agendaItem->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
|
||||||
|
return 'sermon';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sermonPatterns = Setting::get('agenda_sermon_matching');
|
||||||
|
if ($sermonPatterns === null) {
|
||||||
|
return 'agenda_item';
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = array_map('trim', explode(',', $sermonPatterns));
|
||||||
|
|
||||||
|
return app(AgendaMatcherService::class)->matchesAny($agendaItem->title, $patterns)
|
||||||
|
? 'sermon'
|
||||||
|
: 'agenda_item';
|
||||||
|
}
|
||||||
|
|
||||||
private static function safeFilename(string $name): string
|
private static function safeFilename(string $name): string
|
||||||
{
|
{
|
||||||
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
|
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProFileGenerator;
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
|
|
||||||
class ProExportService
|
class ProExportService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MacroResolutionService $macroResolutionService,
|
private readonly MacroResolutionService $macroResolutionService,
|
||||||
|
private readonly ServiceImageResolver $imageResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function generateProFile(Song $song, ?Service $service = null): string
|
public function generateProFile(Song $song, ?Service $service = null): string
|
||||||
|
|
@ -51,6 +53,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
||||||
|
|
||||||
$groups = [];
|
$groups = [];
|
||||||
$seenLabelIds = [];
|
$seenLabelIds = [];
|
||||||
|
$background = $this->backgroundData($service);
|
||||||
|
|
||||||
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
|
||||||
$label = $arrangementLabel->label;
|
$label = $arrangementLabel->label;
|
||||||
|
|
@ -75,6 +78,10 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
||||||
$slideData['translation'] = $slide->text_content_translated;
|
$slideData['translation'] = $slide->text_content_translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) {
|
||||||
|
$slideData['background'] = $background;
|
||||||
|
}
|
||||||
|
|
||||||
if ($service !== null) {
|
if ($service !== null) {
|
||||||
$macros = $this->macroResolutionService->macrosForSlide(
|
$macros = $this->macroResolutionService->macrosForSlide(
|
||||||
$service,
|
$service,
|
||||||
|
|
@ -101,6 +108,35 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
||||||
return $groups;
|
return $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function backgroundData(?Service $service): ?array
|
||||||
|
{
|
||||||
|
if ($service === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$background = $this->imageResolver->backgroundFor($service);
|
||||||
|
|
||||||
|
if ($background === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => Storage::disk('public')->path($background),
|
||||||
|
'format' => 'JPG',
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFullCoverImageSlide(object $slide, array $slideData): bool
|
||||||
|
{
|
||||||
|
if (! isset($slideData['media'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($slide->cover_mode ?? null) === true;
|
||||||
|
}
|
||||||
|
|
||||||
private function buildArrangements(Song $song): array
|
private function buildArrangements(Song $song): array
|
||||||
{
|
{
|
||||||
$arrangements = [];
|
$arrangements = [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('slides', function (Blueprint $table) {
|
||||||
|
$table->boolean('cover_mode')->nullable()->after('thumbnail_filename');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('slides', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('cover_mode');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -7,10 +7,17 @@
|
||||||
use App\Models\MacroAssignment;
|
use App\Models\MacroAssignment;
|
||||||
use App\Models\MacroCollection;
|
use App\Models\MacroCollection;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceAgendaItem;
|
||||||
|
use App\Models\Slide;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlaylistExportService;
|
||||||
|
use App\Services\ProBundleExportService;
|
||||||
use App\Services\ProExportService;
|
use App\Services\ProExportService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ProPresenter\Parser\ProBundleReader;
|
||||||
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class ProFileExportTest extends TestCase
|
final class ProFileExportTest extends TestCase
|
||||||
|
|
@ -268,6 +275,187 @@ public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): voi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_export_mit_service_background_enthaelt_background_auf_allen_song_folien(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'background_filename' => 'slides/background.jpg',
|
||||||
|
]);
|
||||||
|
$song = $this->createSongWithContent();
|
||||||
|
$expectedPath = Storage::disk('public')->path('slides/background.jpg');
|
||||||
|
|
||||||
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||||
|
$slides = $this->allParserSlides($parserSong);
|
||||||
|
|
||||||
|
$this->assertNotEmpty($slides);
|
||||||
|
foreach ($slides as $slide) {
|
||||||
|
$this->assertTrue($slide->hasBackgroundMedia());
|
||||||
|
$this->assertSame($expectedPath, $slide->getBackgroundMediaUrl());
|
||||||
|
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
$song = $this->createSongWithContent();
|
||||||
|
|
||||||
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||||
|
|
||||||
|
foreach ($this->allParserSlides($parserSong) as $slide) {
|
||||||
|
$this->assertFalse($slide->hasBackgroundMedia());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sermon_export_ueberspringt_background_bei_full_cover_folien(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
||||||
|
Storage::disk('public')->put('slides/contain.jpg', 'contain-image');
|
||||||
|
Storage::disk('public')->put('slides/cover.jpg', 'cover-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'background_filename' => 'slides/background.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'contain.jpg',
|
||||||
|
'stored_filename' => 'slides/contain.jpg',
|
||||||
|
'cover_mode' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'cover.jpg',
|
||||||
|
'stored_filename' => 'slides/cover.jpg',
|
||||||
|
'cover_mode' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon');
|
||||||
|
$slides = $this->allParserSlides(ProBundleReader::read($bundlePath)->getSong());
|
||||||
|
|
||||||
|
$this->assertCount(2, $slides);
|
||||||
|
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||||
|
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
||||||
|
|
||||||
|
@unlink($bundlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_information_und_moderation_exports_erhalten_keinen_background(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
||||||
|
Storage::disk('public')->put('slides/information.jpg', 'information-image');
|
||||||
|
Storage::disk('public')->put('slides/moderation.jpg', 'moderation-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'background_filename' => 'slides/background.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'information',
|
||||||
|
'original_filename' => 'information.jpg',
|
||||||
|
'stored_filename' => 'slides/information.jpg',
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'moderation',
|
||||||
|
'original_filename' => 'moderation.jpg',
|
||||||
|
'stored_filename' => 'slides/moderation.jpg',
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (['information', 'moderation'] as $blockType) {
|
||||||
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
|
||||||
|
|
||||||
|
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
|
||||||
|
$this->assertFalse($slide->hasBackgroundMedia());
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($bundlePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
||||||
|
Storage::disk('public')->put('slides/information.jpg', 'information-image');
|
||||||
|
Storage::disk('public')->put('slides/sermon-contain.jpg', 'sermon-contain-image');
|
||||||
|
Storage::disk('public')->put('slides/sermon-cover.jpg', 'sermon-cover-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Playlist Background',
|
||||||
|
'date' => now(),
|
||||||
|
'background_filename' => 'slides/background.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => null,
|
||||||
|
'type' => 'information',
|
||||||
|
'original_filename' => 'information.jpg',
|
||||||
|
'stored_filename' => 'slides/information.jpg',
|
||||||
|
'uploaded_at' => now()->subDay(),
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sermonItem = 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' => $sermonItem->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'sermon-contain.jpg',
|
||||||
|
'stored_filename' => 'slides/sermon-contain.jpg',
|
||||||
|
'cover_mode' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'service_agenda_item_id' => $sermonItem->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'sermon-cover.jpg',
|
||||||
|
'stored_filename' => 'slides/sermon-cover.jpg',
|
||||||
|
'cover_mode' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
$informationSong = $playlist->getEmbeddedSong('Informationen.pro');
|
||||||
|
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
||||||
|
|
||||||
|
$this->assertNotNull($informationSong);
|
||||||
|
$this->assertNotNull($sermonSong);
|
||||||
|
|
||||||
|
foreach ($this->allParserSlides($informationSong) as $slide) {
|
||||||
|
$this->assertFalse($slide->hasBackgroundMedia());
|
||||||
|
}
|
||||||
|
|
||||||
|
$sermonSlides = $this->allParserSlides($sermonSong);
|
||||||
|
$this->assertCount(2, $sermonSlides);
|
||||||
|
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
|
||||||
|
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
private function createMacroForExport(string $name, array $attributes = []): Macro
|
private function createMacroForExport(string $name, array $attributes = []): Macro
|
||||||
{
|
{
|
||||||
$macro = Macro::factory()->create(array_merge([
|
$macro = Macro::factory()->create(array_merge([
|
||||||
|
|
@ -305,4 +493,27 @@ private function assertStringContains(string $needle, ?string $haystack): void
|
||||||
"Failed asserting that '{$haystack}' contains '{$needle}'"
|
"Failed asserting that '{$haystack}' contains '{$needle}'"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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