diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index a01af04..ac67a6f 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Service; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -52,7 +53,12 @@ public function callback(): RedirectResponse Auth::login($user, remember: true); - return redirect()->intended(route('dashboard')); + $service = Service::nextUpcoming()->first(); + $target = $service + ? route('services.edit', $service->id) + : route('services.index'); + + return redirect()->intended($target); } /** diff --git a/app/Http/Controllers/ExportProFileController.php b/app/Http/Controllers/ExportProFileController.php index 9dd8178..5cca89f 100644 --- a/app/Http/Controllers/ExportProFileController.php +++ b/app/Http/Controllers/ExportProFileController.php @@ -15,7 +15,9 @@ public function store(Request $request): JsonResponse $request->validate([ 'type' => ['required', Rule::in(['prefix', 'postfix'])], 'files' => ['required', 'array'], - 'files.*' => ['required', 'file', 'max:10240', 'extensions:pro'], + 'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'], + ], [ + 'files.*.extensions' => 'Nur .pro- und .probundle-Dateien sind erlaubt.', ]); $type = $request->input('type'); diff --git a/app/Services/NameTagResolver.php b/app/Services/NameTagResolver.php index dbb66db..8313b14 100644 --- a/app/Services/NameTagResolver.php +++ b/app/Services/NameTagResolver.php @@ -21,6 +21,12 @@ public function moderatorFor(Service $service): ?string return $override; } + // Prefer the person whose responsible role is "Moderation". + $byRole = $this->nameForRole($service, 'moderation'); + if ($byRole !== null) { + return $byRole; + } + $firstAgendaItem = $service->agendaItems() ->where('is_before_event', false) ->orderBy('sort_order') @@ -32,14 +38,7 @@ public function moderatorFor(Service $service): ?string public function worshipLeaderFor(Service $service): ?string { - $worshipItem = $service->agendaItems() - ->where('is_before_event', false) - ->orderBy('sort_order') - ->orderBy('id') - ->get() - ->first(fn (ServiceAgendaItem $item) => $this->isWorshipItem($item)); - - return $worshipItem ? $this->namesFromResponsible($worshipItem->responsible) : null; + return $this->nameForRole($service, 'lobpreis'); } public function preacherFor(Service $service): ?string @@ -54,6 +53,12 @@ public function preacherFor(Service $service): ?string return $preacherName; } + // Prefer the person whose responsible role is "Predigt". + $byRole = $this->nameForRole($service, 'predigt'); + if ($byRole !== null) { + return $byRole; + } + $sermonItem = $service->agendaItems() ->where('is_before_event', false) ->whereNull('service_song_id') @@ -65,6 +70,29 @@ public function preacherFor(Service $service): ?string return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null; } + /** + * Find the first visible agenda item carrying a responsible person whose + * role (persons[].service, e.g. "[Lobpreis]") matches $roleNeedle, and + * return that person's display name. + */ + private function nameForRole(Service $service, string $roleNeedle): ?string + { + $items = $service->agendaItems() + ->where('is_before_event', false) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + foreach ($items as $item) { + $name = $this->nameFromResponsibleByRole($item->responsible, $roleNeedle); + if ($name !== null) { + return $name; + } + } + + return null; + } + private function filledString(?string $value): ?string { $trimmed = trim((string) $value); @@ -78,10 +106,17 @@ private function namesFromResponsible(mixed $responsible): ?string return null; } - $people = Arr::isAssoc($responsible) ? [$responsible] : $responsible; + // Real CTS shape: { "text": "...", "persons": [ { "service": "[Lobpreis]", "person": { "title": "...", ... } } ] } + if (array_key_exists('persons', $responsible)) { + $entries = $responsible['persons']; + } elseif (Arr::isAssoc($responsible)) { + $entries = [$responsible]; + } else { + $entries = $responsible; + } - $names = collect($people) - ->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person)) + $names = collect($entries) + ->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry)) ->filter() ->values() ->all(); @@ -89,35 +124,94 @@ private function namesFromResponsible(mixed $responsible): ?string return $names === [] ? null : implode(', ', $names); } - private function nameFromResponsiblePerson(mixed $person): ?string + /** + * Extract a person name from a responsible entry. + * Handles both the real CTS shape (entry has a 'person' sub-key) and the + * legacy/test shape (entry is a flat array with 'name'/'firstName'/'lastName' or a string). + */ + private function nameFromResponsibleEntry(mixed $entry): ?string { - if (is_string($person)) { - return $this->filledString($person); + if (is_string($entry)) { + return $this->filledString($entry); } - if (! is_array($person)) { + if (! is_array($entry)) { return null; } - $name = $this->filledString($person['name'] ?? null); + // Real CTS shape: { "service": "[Lobpreis]", "person": { "title": "Kornelius Weiß", ... } } + if (isset($entry['person']) && is_array($entry['person'])) { + return $this->nameFromPersonNode($entry['person']); + } + + // Legacy / test shape: { "name": "Anna Müller" } or { "firstName": "...", "lastName": "..." } + return $this->nameFromPersonNode($entry); + } + + /** Extract display name from a person node (title preferred, then firstName+lastName). */ + private function nameFromPersonNode(mixed $node): ?string + { + if (! is_array($node)) { + return null; + } + + $title = $this->filledString($node['title'] ?? null); + if ($title !== null) { + return $title; + } + + $name = $this->filledString($node['name'] ?? null); if ($name !== null) { return $name; } - $firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? ''; - $lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? ''; + $domainAttrs = $node['domainAttributes'] ?? []; + $firstName = $this->filledString( + $node['firstName'] ?? $node['first_name'] ?? ($domainAttrs['firstName'] ?? null) + ) ?? ''; + $lastName = $this->filledString( + $node['lastName'] ?? $node['last_name'] ?? ($domainAttrs['lastName'] ?? null) + ) ?? ''; $fullName = trim($firstName.' '.$lastName); return $fullName === '' ? null : $fullName; } - private function isWorshipItem(ServiceAgendaItem $item): bool + /** + * Scan a responsible array for a person whose role (persons[].service) + * contains $roleNeedle (e.g. 'lobpreis', 'predigt', 'moderation'). + * Returns the person's display name, or null if none found. + */ + private function nameFromResponsibleByRole(mixed $responsible, string $roleNeedle): ?string { - $title = Str::lower($item->title); - $type = Str::lower($item->type ?? ''); + if (! is_array($responsible) || $responsible === []) { + return null; + } - return str_contains($title, 'lobpreis') - || str_contains($type, 'lobpreis'); + // Real CTS shape: { "persons": [ { "service": "[Lobpreis]", "person": { ... } } ] } + $entries = array_key_exists('persons', $responsible) ? $responsible['persons'] : []; + + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $role = $this->normalizeRole($entry['service'] ?? ''); + if ($role !== '' && str_contains($role, $roleNeedle)) { + $name = $this->nameFromResponsibleEntry($entry); + if ($name !== null) { + return $name; + } + } + } + + return null; + } + + /** Strip brackets, whitespace, and lowercase a role string for comparison. */ + private function normalizeRole(string $role): string + { + return Str::lower(preg_replace('/[^a-zA-ZäöüÄÖÜß]/', '', $role) ?? ''); } private function isSermonItem(ServiceAgendaItem $item): bool diff --git a/app/Services/NameTagSlideBuilder.php b/app/Services/NameTagSlideBuilder.php index 270f06f..419eb48 100644 --- a/app/Services/NameTagSlideBuilder.php +++ b/app/Services/NameTagSlideBuilder.php @@ -37,6 +37,10 @@ public function build(string $name, string $title): ?array ], ]; } + + // macro_id is configured but the Macro record is missing — still return a slide + // without a macro so the name tag is not silently dropped. + return ['text' => $name."\n".$title]; } $macroName = Setting::get('namenseinblender_macro_name'); diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 6d00534..8fff244 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProPlaylistGenerator; +use ZipArchive; class PlaylistExportService { @@ -169,11 +170,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda ); $keyvisualFallbackEmitted = true; } else { - $this->addHeadlinePresentation( + $this->addHeadlineItem( $item, - $tempDir, $playlistItems, - $embeddedFiles, ); } } @@ -201,7 +200,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } - $this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir); + $this->injectExportProFiles($playlistItems, $embeddedFiles); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); @@ -309,7 +308,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } - $this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir); + $this->injectExportProFiles($playlistItems, $embeddedFiles); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); @@ -514,99 +513,118 @@ private function addKeyVisualFallbackPresentation( ]; } - private function addHeadlinePresentation( + private function addHeadlineItem( ServiceAgendaItem $item, - string $tempDir, array &$playlistItems, - array &$embeddedFiles, ): void { - $label = $item->title ?: 'Ablaufpunkt'; - $slideData = ['text' => $label]; - - $groups = [ - [ - 'name' => $label, - 'color' => [0, 0, 0, 1], - 'slides' => [$slideData], - ], - ]; - $arrangements = [ - [ - 'name' => 'normal', - 'groupNames' => [$label], - ], - ]; - - $safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label); - $proFilename = $safeLabel.'-headline-'.uniqid().'.pro'; - $proPath = $tempDir.'/'.$proFilename; - - $this->writeProFile($proPath, $label, $groups, $arrangements); - - $embeddedFiles[$proFilename] = file_get_contents($proPath); - $playlistItems[] = [ - 'type' => 'presentation', - 'name' => $label, - 'path' => $proFilename, + 'type' => 'header', + 'name' => $item->title ?: 'Ablaufpunkt', + 'color' => [0.5, 0.5, 0.5, 1.0], ]; } - private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, string $tempDir): void + private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void { - $prefixFiles = ExportProFile::prefix()->orderBy('order')->get(); - $postfixFiles = ExportProFile::postfix()->orderBy('order')->get(); - - $prefixItems = []; $prefixEmbedded = []; + $prefixItems = $this->buildExportProItems( + ExportProFile::prefix()->orderBy('order')->get(), + 'PREFIX', + $prefixEmbedded, + ); - foreach ($prefixFiles as $file) { - if (! Storage::disk('local')->exists($file->stored_path)) { - continue; - } - - $bytes = Storage::disk('local')->get($file->stored_path); - if ($bytes === null) { - continue; - } - - $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); - $embeddedFilename = 'PREFIX_'.$file->order.'_'.$safeBase.'.pro'; - $prefixEmbedded[$embeddedFilename] = $bytes; - $prefixItems[] = [ - 'type' => 'presentation', - 'name' => pathinfo($file->original_name, PATHINFO_FILENAME), - 'path' => $embeddedFilename, - ]; - } - - $postfixItems = []; $postfixEmbedded = []; - - foreach ($postfixFiles as $file) { - if (! Storage::disk('local')->exists($file->stored_path)) { - continue; - } - - $bytes = Storage::disk('local')->get($file->stored_path); - if ($bytes === null) { - continue; - } - - $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); - $embeddedFilename = 'POSTFIX_'.$file->order.'_'.$safeBase.'.pro'; - $postfixEmbedded[$embeddedFilename] = $bytes; - $postfixItems[] = [ - 'type' => 'presentation', - 'name' => pathinfo($file->original_name, PATHINFO_FILENAME), - 'path' => $embeddedFilename, - ]; - } + $postfixItems = $this->buildExportProItems( + ExportProFile::postfix()->orderBy('order')->get(), + 'POSTFIX', + $postfixEmbedded, + ); $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); $embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded); } + /** @param \Illuminate\Support\Collection $files */ + private function buildExportProItems(\Illuminate\Support\Collection $files, string $marker, array &$embedded): array + { + $items = []; + + foreach ($files as $file) { + if (! Storage::disk('local')->exists($file->stored_path)) { + continue; + } + + $ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION)); + $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); + $embeddedProName = $marker.'_'.$file->order.'_'.$safeBase.'.pro'; + $displayName = pathinfo($file->original_name, PATHINFO_FILENAME); + + if ($ext === 'probundle') { + $storedAbsPath = Storage::disk('local')->path($file->stored_path); + $zip = new ZipArchive; + + if ($zip->open($storedAbsPath) !== true) { + continue; + } + + $proBytes = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + if ($entryName === false) { + continue; + } + + if (str_ends_with($entryName, '/')) { + continue; + } + + $entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION)); + $entryBytes = $zip->getFromIndex($i); + if ($entryBytes === false) { + continue; + } + + if ($entryExt === 'pro') { + $proBytes = $entryBytes; + } else { + $mediaName = basename($entryName); + if (! isset($embedded[$mediaName])) { + $embedded[$mediaName] = $entryBytes; + } + } + } + + $zip->close(); + + if ($proBytes === null) { + continue; + } + + $embedded[$embeddedProName] = $proBytes; + $items[] = [ + 'type' => 'presentation', + 'name' => $displayName, + 'path' => $embeddedProName, + ]; + } else { + $bytes = Storage::disk('local')->get($file->stored_path); + if ($bytes === null) { + continue; + } + + $embedded[$embeddedProName] = $bytes; + $items[] = [ + 'type' => 'presentation', + 'name' => $displayName, + 'path' => $embeddedProName, + ]; + } + } + + return $items; + } + protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void { ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index 814e608..73e9e0f 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -54,6 +54,24 @@ private function buildGroups(Song $song, ?Service $service = null): array $seenSectionIds = []; $background = $this->backgroundData($service); + // Pre-compute the total slide count across the whole song (same filtering as the loop below) + // so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide. + $totalSlidesInSong = 0; + $seenForCount = []; + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; + if ($section === null || $section->label === null) { + continue; + } + if (in_array($section->id, $seenForCount, true)) { + continue; + } + $seenForCount[] = $section->id; + $totalSlidesInSong += $section->slides->count(); + } + + $globalSlideIndex = 0; + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { $section = $arrangementSection->section; $label = $section?->label; @@ -69,9 +87,8 @@ private function buildGroups(Song $song, ?Service $service = null): array $slides = []; $sectionSlides = $section->slides->sortBy('order')->values(); - $totalSlides = $sectionSlides->count(); - foreach ($sectionSlides as $slideIndex => $slide) { + foreach ($sectionSlides as $slide) { $slideData = ['text' => $slide->text_content ?? '']; if ($slide->text_content_translated) { @@ -86,7 +103,7 @@ private function buildGroups(Song $song, ?Service $service = null): array $macros = $this->macroResolutionService->macrosForSlide( $service, 'song', - ['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id], + ['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id], ); if (! empty($macros)) { @@ -96,6 +113,7 @@ private function buildGroups(Song $song, ?Service $service = null): array } $slides[] = $slideData; + $globalSlideIndex++; } $groups[] = [ diff --git a/resources/js/Pages/Settings/ExportProFiles.vue b/resources/js/Pages/Settings/ExportProFiles.vue index 70ceb1e..79c732e 100644 --- a/resources/js/Pages/Settings/ExportProFiles.vue +++ b/resources/js/Pages/Settings/ExportProFiles.vue @@ -94,7 +94,7 @@ async function deleteFile(id) {

Export-Dateien

- Diese .pro-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt. + Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.

@@ -125,13 +125,13 @@ async function deleteFile(id) { - {{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }} + {{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }} Mehrere Dateien möglich - {{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }} + {{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }} Mehrere Dateien möglich route('dashboard'); + $service = Service::nextUpcoming()->first(); + + return $service + ? redirect()->route('services.edit', $service->id) + : redirect()->route('services.index'); })->name('dev-login'); }); } diff --git a/tests/Feature/ExportProFileInjectionTest.php b/tests/Feature/ExportProFileInjectionTest.php new file mode 100644 index 0000000..58d1eba --- /dev/null +++ b/tests/Feature/ExportProFileInjectionTest.php @@ -0,0 +1,278 @@ + $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 buildProbundleZip(string $proContent, array $mediaFiles = []): string + { + $tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle'; + + $zip = new ZipArchive(); + $zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('song.pro', $proContent); + + foreach ($mediaFiles as $name => $content) { + $zip->addFromString($name, $content); + } + + $zip->close(); + + return $tmpPath; + } + + 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_prefix_wird_korrekt_injiziert(): void + { + $user = User::factory()->create(); + + $service = Service::factory()->create([ + 'title' => 'Bundle Test Service', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Test Song'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Test Song', + 'order' => 1, + ]); + + $proContent = 'dummy-pro-content'; + $mediaContent = 'dummy-media-bytes'; + $bundlePath = $this->buildProbundleZip($proContent, ['background.jpg' => $mediaContent]); + + $storedPath = 'export-pro-files/intro.probundle'; + Storage::disk('local')->put($storedPath, file_get_contents($bundlePath)); + unlink($bundlePath); + + ExportProFile::create([ + 'type' => 'prefix', + 'original_name' => 'intro.probundle', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entries = $playlist->getEntries(); + $entryNames = array_map(fn ($e) => $e->getName(), $entries); + + $this->assertContains('intro', $entryNames, 'Expected prefix presentation entry named "intro"'); + $songPos = array_search('Test Song', $entryNames, true); + $prefixPos = array_search('intro', $entryNames, true); + $this->assertNotFalse($prefixPos, 'Prefix entry not found'); + $this->assertNotFalse($songPos, 'Song entry not found'); + $this->assertLessThan($songPos, $prefixPos, 'Prefix should appear before song'); + + $embeddedFiles = $playlist->getEmbeddedFiles(); + $this->assertArrayHasKey('PREFIX_1_intro.pro', $embeddedFiles, 'Inner .pro should be embedded as PREFIX_1_intro.pro'); + $this->assertSame($proContent, $embeddedFiles['PREFIX_1_intro.pro']); + + $this->assertArrayHasKey('background.jpg', $embeddedFiles, 'Media file from .probundle should be embedded'); + $this->assertSame($mediaContent, $embeddedFiles['background.jpg']); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_probundle_postfix_wird_nach_songs_injiziert(): void + { + $service = Service::factory()->create([ + 'title' => 'Postfix Bundle Service', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Worship Song'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Worship Song', + 'order' => 1, + ]); + + $bundlePath = $this->buildProbundleZip('outro-pro-content', ['outro-media.jpg' => 'outro-media-bytes']); + $storedPath = 'export-pro-files/outro.probundle'; + Storage::disk('local')->put($storedPath, file_get_contents($bundlePath)); + unlink($bundlePath); + + ExportProFile::create([ + 'type' => 'postfix', + 'original_name' => 'outro.probundle', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entries = $playlist->getEntries(); + $entryNames = array_map(fn ($e) => $e->getName(), $entries); + + $songPos = array_search('Worship Song', $entryNames, true); + $postfixPos = array_search('outro', $entryNames, true); + $this->assertNotFalse($postfixPos, 'Postfix entry not found'); + $this->assertNotFalse($songPos, 'Song entry not found'); + $this->assertLessThan($postfixPos, $songPos, 'Song should appear before postfix'); + + $embeddedFiles = $playlist->getEmbeddedFiles(); + $this->assertArrayHasKey('POSTFIX_1_outro.pro', $embeddedFiles); + $this->assertSame('outro-pro-content', $embeddedFiles['POSTFIX_1_outro.pro']); + $this->assertArrayHasKey('outro-media.jpg', $embeddedFiles); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void + { + $service = Service::factory()->create([ + 'title' => 'Skip Bundle Service', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Einziger Song'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Einziger Song', + 'order' => 1, + ]); + + $tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle'; + $zip = new ZipArchive(); + $zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('media.jpg', 'media-only'); + $zip->close(); + + $storedPath = 'export-pro-files/broken.probundle'; + Storage::disk('local')->put($storedPath, file_get_contents($tmpPath)); + unlink($tmpPath); + + ExportProFile::create([ + 'type' => 'prefix', + 'original_name' => 'broken.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('broken', $entryNames); + $this->assertContains('Einziger Song', $entryNames); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_plain_pro_prefix_weiterhin_korrekt(): void + { + $service = Service::factory()->create([ + 'title' => 'Plain Pro Service', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Lied'); + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Lied', + 'order' => 1, + ]); + + $storedPath = 'export-pro-files/welcome.pro'; + Storage::disk('local')->put($storedPath, 'welcome-pro-bytes'); + + ExportProFile::create([ + 'type' => 'prefix', + 'original_name' => 'welcome.pro', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entries = $playlist->getEntries(); + $entryNames = array_map(fn ($e) => $e->getName(), $entries); + + $this->assertContains('welcome', $entryNames); + $prefixPos = array_search('welcome', $entryNames, true); + $songPos = array_search('Lied', $entryNames, true); + $this->assertLessThan($songPos, $prefixPos, 'Plain .pro prefix should appear before song'); + + $embeddedFiles = $playlist->getEmbeddedFiles(); + $this->assertArrayHasKey('PREFIX_1_welcome.pro', $embeddedFiles); + $this->assertSame('welcome-pro-bytes', $embeddedFiles['PREFIX_1_welcome.pro']); + + $this->cleanupTempDir($result['temp_dir']); + } +} diff --git a/tests/Feature/ExportProFileUploadTest.php b/tests/Feature/ExportProFileUploadTest.php new file mode 100644 index 0000000..a797788 --- /dev/null +++ b/tests/Feature/ExportProFileUploadTest.php @@ -0,0 +1,102 @@ +create(); + $file = UploadedFile::fake()->create('intro.pro', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'prefix', + 'files' => [$file], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('files.0.original_name', 'intro.pro'); + } + + public function test_probundle_datei_wird_als_prefix_akzeptiert(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('intro.probundle', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'prefix', + 'files' => [$file], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('files.0.original_name', 'intro.probundle'); + } + + public function test_pro_datei_wird_als_postfix_akzeptiert(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('outro.pro', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'postfix', + 'files' => [$file], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('files.0.original_name', 'outro.pro'); + } + + public function test_probundle_datei_wird_als_postfix_akzeptiert(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('outro.probundle', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'postfix', + 'files' => [$file], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('files.0.original_name', 'outro.probundle'); + } + + public function test_unerlaubte_dateiendung_wird_abgelehnt(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('document.txt', 10, 'text/plain'); + + $response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [ + 'type' => 'prefix', + 'files' => [$file], + ]); + + $response->assertStatus(422); + } + + public function test_unerlaubte_dateiendung_postfix_wird_abgelehnt(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('document.txt', 10, 'text/plain'); + + $response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [ + 'type' => 'postfix', + 'files' => [$file], + ]); + + $response->assertStatus(422); + } +} diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php index 8ff3774..adc008c 100644 --- a/tests/Feature/KeyVisualFallbackTest.php +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -47,24 +47,22 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); - $embeddedProFiles = $playlist->getEmbeddedProFiles(); - // Content-less item before any real content → HEADLINE (not keyvisual fallback) - $this->assertCount(1, $embeddedProFiles); + // Content-less item before any real content → HEADER playlist item (no embedded .pro) + $this->assertCount(0, $playlist->getEmbeddedProFiles()); $this->assertSame($slideCountBefore, Slide::count()); - // Headline filename matches pattern: Begrüßung-headline-.pro - $headlineKey = null; - foreach (array_keys($embeddedProFiles) as $key) { - if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) { - $headlineKey = $key; + // Playlist has a header entry named 'Begrüßung' + $headerEntry = null; + foreach ($playlist->getEntries() as $entry) { + if ($entry->isHeader() && $entry->getName() === 'Begrüßung') { + $headerEntry = $entry; break; } } - $this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung'); - $this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey); + $this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung'); - // No KEY_VISUAL.jpg embedded (headline has no background media) + // No KEY_VISUAL.jpg embedded (header has no background media) $this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->cleanupTempDir($result['temp_dir']); @@ -189,20 +187,20 @@ public function test_empty_non_song_item_before_content_becomes_headline(): void $playlist = ProPlaylistReader::read($result['path']); $embeddedProFiles = $playlist->getEmbeddedProFiles(); - // Content-less item before real content → HEADLINE (adds a playlist entry) - // So we expect 2 embedded pro files: song + headline - $this->assertCount(2, $embeddedProFiles); + // Content-less item before real content → HEADER playlist item (no embedded .pro for headline) + // Only the song .pro is embedded + $this->assertCount(1, $embeddedProFiles); $this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro')); - // Headline filename matches pattern: Begrüßung ohne Folien-headline-.pro - $headlineKey = null; - foreach (array_keys($embeddedProFiles) as $key) { - if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) { - $headlineKey = $key; + // Playlist has a header entry named 'Begrüßung ohne Folien' + $headerEntry = null; + foreach ($playlist->getEntries() as $entry) { + if ($entry->isHeader() && $entry->getName() === 'Begrüßung ohne Folien') { + $headerEntry = $entry; break; } } - $this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien'); + $this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung ohne Folien'); // No KEY_VISUAL.jpg embedded (no keyvisual configured) $this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); diff --git a/tests/Feature/NameTagResolverTest.php b/tests/Feature/NameTagResolverTest.php index 2af6632..fbfb2aa 100644 --- a/tests/Feature/NameTagResolverTest.php +++ b/tests/Feature/NameTagResolverTest.php @@ -139,21 +139,31 @@ expect($name)->toBeNull(); }); -test('worship leader resolves from lobpreis agenda item responsibles', function () { +test('worship leader resolves from lobpreis role in responsible persons', function () { $service = Service::factory()->create(); ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Begrüßung', 'is_before_event' => false, - 'responsible' => [['name' => 'Moderator Max']], + 'responsible' => [ + 'text' => '[Moderation]', + 'persons' => [ + ['service' => '[Moderation]', 'accepted' => true, 'person' => ['title' => 'Moderator Max']], + ], + ], 'sort_order' => 1, ]); ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Lobpreis', 'is_before_event' => false, - 'responsible' => [['name' => 'Lea Leiter']], + 'responsible' => [ + 'text' => '[Lobpreis]', + 'persons' => [ + ['service' => '[Lobpreis]', 'accepted' => true, 'person' => ['title' => 'Lea Leiter']], + ], + ], 'sort_order' => 2, ]); @@ -177,3 +187,124 @@ expect($name)->toBeNull(); }); + +test('name extracted from real cts persons shape via person.title', function () { + $service = Service::factory()->create(['moderator_name' => null]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'sort_order' => 1, + 'responsible' => [ + 'text' => '[Moderation]', + 'persons' => [ + [ + 'service' => '[Moderation]', + 'accepted' => true, + 'person' => [ + 'title' => 'Kornelius Weiß', + 'domainAttributes' => ['firstName' => 'Kornelius', 'lastName' => 'Weiß'], + ], + ], + ], + ], + ]); + + $name = app(NameTagResolver::class)->moderatorFor($service); + + expect($name)->toBe('Kornelius Weiß'); +}); + +test('name falls back to domainAttributes when person.title is absent', function () { + $service = Service::factory()->create(['moderator_name' => null]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'sort_order' => 1, + 'responsible' => [ + 'text' => '[Moderation]', + 'persons' => [ + [ + 'service' => '[Moderation]', + 'accepted' => true, + 'person' => [ + 'domainAttributes' => ['firstName' => 'Anna', 'lastName' => 'Müller'], + ], + ], + ], + ], + ]); + + $name = app(NameTagResolver::class)->moderatorFor($service); + + expect($name)->toBe('Anna Müller'); +}); + +test('worship leader resolves from persons service role lobpreis in real cts shape', function () { + $service = Service::factory()->create(); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'sort_order' => 1, + 'responsible' => [ + 'text' => '[Moderation]', + 'persons' => [ + [ + 'service' => '[Moderation]', + 'accepted' => true, + 'person' => ['title' => 'Moderator Max'], + ], + ], + ], + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreis', + 'is_before_event' => false, + 'sort_order' => 2, + 'responsible' => [ + 'text' => '[Lobpreis]', + 'persons' => [ + [ + 'service' => '[Lobpreis]', + 'accepted' => true, + 'person' => ['title' => 'Kornelius Weiß'], + ], + ], + ], + ]); + + $name = app(NameTagResolver::class)->worshipLeaderFor($service); + + expect($name)->toBe('Kornelius Weiß'); +}); + +test('worship leader returns null when no person has lobpreis role in real cts shape', function () { + $service = Service::factory()->create(); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreis', + 'is_before_event' => false, + 'sort_order' => 1, + 'responsible' => [ + 'text' => '[Moderation]', + 'persons' => [ + [ + 'service' => '[Moderation]', + 'accepted' => true, + 'person' => ['title' => 'Moderator Max'], + ], + ], + ], + ]); + + $name = app(NameTagResolver::class)->worshipLeaderFor($service); + + expect($name)->toBeNull(); +}); diff --git a/tests/Feature/NameTagSlideBuilderTest.php b/tests/Feature/NameTagSlideBuilderTest.php index 5a17504..7be9c8f 100644 --- a/tests/Feature/NameTagSlideBuilderTest.php +++ b/tests/Feature/NameTagSlideBuilderTest.php @@ -1,7 +1,11 @@ build('Max Mustermann', 'Moderation'))->toBeNull(); @@ -37,3 +41,26 @@ expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration") ->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt"); }); + +test('build returns slide with macro when macro_id points to existing macro', function () { + $macro = Macro::factory()->create([ + 'name' => 'Namenseinblender', + 'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ]); + Setting::set('namenseinblender_macro_id', (string) $macro->id); + + $slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation'); + + expect($slide)->toHaveKey('text', "Lea Leiter\nModeration") + ->and($slide)->toHaveKey('macro') + ->and($slide['macro']['name'])->toBe('Namenseinblender') + ->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); +}); + +test('build returns slide without macro when macro_id points to missing macro', function () { + Setting::set('namenseinblender_macro_id', '99999'); + + $slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation'); + + expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]); +}); diff --git a/tests/Feature/OAuthTest.php b/tests/Feature/OAuthTest.php index 3db0385..a462784 100644 --- a/tests/Feature/OAuthTest.php +++ b/tests/Feature/OAuthTest.php @@ -1,5 +1,6 @@ create(['date' => now()->addDays(7)]); + + $socialiteUser = new SocialiteUser(); $socialiteUser->map([ 'id' => '42', 'name' => 'Max Mustermann', @@ -80,7 +83,7 @@ $response = $this->get('/auth/churchtools/callback'); - $response->assertRedirect(route('dashboard')); + $response->assertRedirect(route('services.edit', $service->id)); $this->assertDatabaseHas('users', [ 'email' => 'max@example.com', @@ -98,13 +101,15 @@ }); it('updates existing user on OAuth callback', function () { + $service = Service::factory()->create(['date' => now()->addDays(7)]); + $existingUser = User::factory()->create([ 'email' => 'max@example.com', 'name' => 'Old Name', 'churchtools_id' => 42, ]); - $socialiteUser = new SocialiteUser; + $socialiteUser = new SocialiteUser(); $socialiteUser->map([ 'id' => '42', 'name' => 'Max Mustermann', @@ -134,7 +139,7 @@ $response = $this->get('/auth/churchtools/callback'); - $response->assertRedirect(route('dashboard')); + $response->assertRedirect(route('services.edit', $service->id)); $existingUser->refresh(); expect($existingUser->name)->toBe('Max Mustermann'); diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php index d380506..d6d21cf 100644 --- a/tests/Feature/ProFileExportTest.php +++ b/tests/Feature/ProFileExportTest.php @@ -298,6 +298,59 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen } } + public function test_export_first_slide_macro_nur_auf_erster_folie_des_ganzen_songs(): void + { + $service = Service::factory()->create(); + // Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total + $song = $this->createSongWithContent(); + $macro = $this->createMacroForExport('Erste Folie Macro'); + MacroAssignment::create([ + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'first_slide', + 'order' => 0, + ]); + + $parserSong = app(ProExportService::class)->generateParserSong($song, $service); + $slides = $this->allParserSlides($parserSong); + + // Exactly 3 slides: Verse-0, Verse-1, Chorus-0 + $this->assertCount(3, $slides); + + // Only the very first slide of the whole song gets the macro + $this->assertTrue($slides[0]->hasMacro(), 'Erste Folie (global index 0) muss Macro haben'); + $this->assertSame('Erste Folie Macro', $slides[0]->getMacroName()); + + // Chorus slide 0 is the first slide of its section but NOT the first of the song → no macro + $this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein first_slide-Macro haben'); + $this->assertFalse($slides[2]->hasMacro(), 'Chorus-Folie 0 darf kein first_slide-Macro haben (nicht erste Folie des Songs)'); + } + + public function test_export_last_slide_macro_nur_auf_letzter_folie_des_ganzen_songs(): void + { + $service = Service::factory()->create(); + // Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total; last = Chorus-0 + $song = $this->createSongWithContent(); + $macro = $this->createMacroForExport('Letzte Folie Macro'); + MacroAssignment::create([ + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'last_slide', + 'order' => 0, + ]); + + $parserSong = app(ProExportService::class)->generateParserSong($song, $service); + $slides = $this->allParserSlides($parserSong); + + $this->assertCount(3, $slides); + + // Only the very last slide of the whole song gets the macro + $this->assertFalse($slides[0]->hasMacro(), 'Verse-Folie 0 darf kein last_slide-Macro haben'); + $this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein last_slide-Macro haben (letzte Folie der Sektion, aber nicht des Songs)'); + $this->assertTrue($slides[2]->hasMacro(), 'Letzte Folie des Songs (global index 2) muss Macro haben'); + $this->assertSame('Letzte Folie Macro', $slides[2]->getMacroName()); + } + public function test_export_ohne_background_enthaelt_keine_background_actions(): void { Storage::fake('public');