diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index adc02e9..b2755b8 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -134,6 +134,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image 'serviceSongs.song.sections.label', 'serviceSongs.song.sections.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label', + 'serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.arrangement', 'slides', 'agendaItems' => fn ($q) => $q->orderBy('sort_order'), @@ -308,6 +309,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image 'song_arrangement_id' => $ss->song_arrangement_id, 'matched_at' => $ss->matched_at?->toJSON(), 'request_sent_at' => $ss->request_sent_at?->toJSON(), + 'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null, 'song' => $ss->song ? [ 'id' => $ss->song->id, 'title' => $ss->song->title, @@ -537,4 +539,30 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection ]) ->values(); } + + /** + * Prueft, ob das Standard-Arrangement des Songs mindestens eine Inhaltsfolie hat. + * Inhaltsfolie = SongSlide in einer NICHT gesperrten Section (locked null = nicht gesperrt), + * die Teil des Standard-Arrangements ist. Relationen sind bereits eager-geladen. + */ + private function defaultArrangementHasContentSlides(Song $song): bool + { + $defaultArrangement = $song->arrangements + ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1) + ->first(); + + if ($defaultArrangement === null) { + return false; + } + + return $defaultArrangement->arrangementSections->contains(function ($arrangementSection): bool { + $section = $arrangementSection->section; + + if ($section === null || (bool) ($section->locked ?? false)) { + return false; + } + + return $section->slides->isNotEmpty(); + }); + } } diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php index c8f3cc9..95eaec3 100644 --- a/app/Http/Controllers/SongController.php +++ b/app/Http/Controllers/SongController.php @@ -5,6 +5,7 @@ use App\Http\Requests\SongRequest; use App\Models\Label; use App\Models\Song; +use App\Services\SongPrefixPostfixService; use App\Services\SongService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -14,6 +15,7 @@ class SongController extends Controller { public function __construct( private readonly SongService $songService, + private readonly SongPrefixPostfixService $songPrefixPostfixService, ) {} public function index(Request $request): JsonResponse @@ -98,6 +100,8 @@ public function update(SongRequest $request, int $id): JsonResponse $song->update($request->validated()); + $this->songPrefixPostfixService->ensure($song); + return response()->json([ 'message' => 'Song erfolgreich aktualisiert', 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])), diff --git a/app/Services/CcliImportService.php b/app/Services/CcliImportService.php index ccb2167..1dda495 100644 --- a/app/Services/CcliImportService.php +++ b/app/Services/CcliImportService.php @@ -169,7 +169,10 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s private function songHasContent(Song $song): bool { - return $song->sections()->whereHas('slides')->exists(); + return $song->sections() + ->where(fn ($q) => $q->where('locked', false)->orWhereNull('locked')) + ->whereHas('slides') + ->exists(); } private function resolveLabel(ParsedCcliSection $section): Label diff --git a/app/Services/CcliPasteParser.php b/app/Services/CcliPasteParser.php index c1240c2..2ab0e72 100644 --- a/app/Services/CcliPasteParser.php +++ b/app/Services/CcliPasteParser.php @@ -38,7 +38,7 @@ public function parse(string $rawText): ParsedCcliSong } if ($firstSectionIndex === null) { - throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); + return $this->parseMetadataOnly($lines, $isMetadataLine); } $headerLines = array_values(array_filter( @@ -136,7 +136,9 @@ public function parse(string $rawText): ParsedCcliSong $parsedSections = $this->mergeTranslatedSections($sections); if ($parsedSections === []) { - throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); + if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) { + throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); + } } return new ParsedCcliSong( @@ -150,6 +152,68 @@ public function parse(string $rawText): ParsedCcliSong ); } + /** + * Build a song from a paste that contains no lyric sections at all. + * + * Allowed as long as it is a real CCLI page (title + CCLI number present); + * otherwise there is genuinely no song and we throw. + * + * @param string[] $lines + * @param Closure(string): bool $isMetadataLine + */ + private function parseMetadataOnly(array $lines, Closure $isMetadataLine): ParsedCcliSong + { + $nonEmpty = array_values(array_filter( + $lines, + fn (string $line): bool => $line !== '', + )); + + $ccliId = null; + $year = null; + $copyrightText = null; + + foreach ($nonEmpty as $line) { + if (! $isMetadataLine($line)) { + continue; + } + + $extractedCcliId = CcliLabels::extractCcliId($line); + if ($extractedCcliId !== null) { + $ccliId = $extractedCcliId; + } + + if (str_contains($line, '©')) { + $copyrightText = $line; + + if (preg_match('/©\s*(\d{4})/u', $line, $matches)) { + $year = $matches[1]; + } + } + } + + $headerLines = array_values(array_filter( + $nonEmpty, + fn (string $line): bool => ! $isMetadataLine($line), + )); + + $title = $headerLines[0] ?? ''; + $author = $headerLines[1] ?? null; + + if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) { + throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); + } + + return new ParsedCcliSong( + title: $title, + author: $author, + ccliId: $ccliId, + year: $year, + copyrightText: $copyrightText, + sourceUrl: null, + sections: [], + ); + } + /** * @param array $sections * @return ParsedCcliSection[] diff --git a/app/Services/NameTagSlideBuilder.php b/app/Services/NameTagSlideBuilder.php index 419eb48..3f0ed4b 100644 --- a/app/Services/NameTagSlideBuilder.php +++ b/app/Services/NameTagSlideBuilder.php @@ -27,20 +27,20 @@ public function build(string $name, string $title): ?array if ($macro !== null) { $collection = $macro->collections->first(); - return [ - 'text' => $name."\n".$title, + return $this->withSubtitle([ + 'text' => $name, 'macro' => [ 'name' => $macro->name, 'uuid' => $macro->uuid, 'collectionName' => $collection?->name ?? '--MAIN--', 'collectionUuid' => $collection?->uuid ?? null, ], - ]; + ], $title); } // 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]; + return $this->withSubtitle(['text' => $name], $title); } $macroName = Setting::get('namenseinblender_macro_name'); @@ -49,14 +49,30 @@ public function build(string $name, string $title): ?array return null; } - return [ - 'text' => $name."\n".$title, + return $this->withSubtitle([ + 'text' => $name, 'macro' => [ 'name' => $macroName, 'uuid' => Setting::get('namenseinblender_macro_uuid'), 'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'), 'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'), ], - ]; + ], $title); + } + + /** + * Add the role line as a separate `subtitle` key so the generator can render + * it smaller and non-bold. Only set when the title is non-empty. + * + * @param array $slideData + * @return array + */ + private function withSubtitle(array $slideData, string $title): array + { + if (trim($title) !== '') { + $slideData['subtitle'] = $title; + } + + return $slideData; } } diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 8fff244..5078ef2 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -11,11 +11,12 @@ use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProPlaylistGenerator; +use ProPresenter\Parser\Zip64Fixer; use ZipArchive; class PlaylistExportService { - /** @return array{path: string, filename: string, skipped: int} */ + /** @return array{path: string, filename: string, skipped: int, warnings: array} */ public function generatePlaylist(Service $service, bool $preview = false): array { $agendaItems = ServiceAgendaItem::where('service_id', $service->id) @@ -38,7 +39,7 @@ public function generatePlaylist(Service $service, bool $preview = false): array /** * @param Collection $agendaItems - * @return array{path: string, filename: string, skipped: int, temp_dir: string} + * @return array{path: string, filename: string, skipped: int, warnings: array, temp_dir: string} */ private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array { @@ -63,6 +64,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $playlistItems = []; $embeddedFiles = []; $skippedUnmatched = 0; + $warnings = []; $moderatorSlideData = $this->buildModeratorSlideData($service); $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; @@ -105,8 +107,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if ($serviceSong->song_id && $serviceSong->song) { $song = $serviceSong->song; - if ($this->countSongLabels($song) === 0) { + if ($this->countSongContentSlides($song) === 0) { $skippedUnmatched++; + $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; continue; } @@ -222,6 +225,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda 'path' => $outputPath, 'filename' => $outputFilename, 'skipped' => $skippedUnmatched, + 'warnings' => $warnings, 'temp_dir' => $tempDir, ]; } @@ -230,7 +234,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda * Legacy export for services without agenda items. * Hardcoded block order: info → songs → moderation → sermon. * - * @return array{path: string, filename: string, skipped: int, temp_dir: string} + * @return array{path: string, filename: string, skipped: int, warnings: array, temp_dir: string} */ private function generatePlaylistLegacy(Service $service, bool $preview = false): array { @@ -244,6 +248,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedEmpty = 0; + $warnings = []; $exportService = app(ProExportService::class); $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid(); @@ -264,8 +269,11 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) foreach ($matchedSongs as $serviceSong) { $song = $serviceSong->song; - if (! $song || $this->countSongLabels($song) === 0) { + if (! $song || $this->countSongContentSlides($song) === 0) { $skippedEmpty++; + if ($song !== null) { + $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; + } continue; } @@ -330,6 +338,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false) 'path' => $outputPath, 'filename' => $outputFilename, 'skipped' => $skippedUnmatched + $skippedEmpty, + 'warnings' => $warnings, 'temp_dir' => $tempDir, ]; } @@ -366,6 +375,7 @@ private function addSlidesFromCollection( 'media' => $imageFilename, 'format' => 'JPG', 'label' => $slide->original_filename, + 'bundleRelative' => true, ]; if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { @@ -561,44 +571,19 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri if ($ext === 'probundle') { $storedAbsPath = Storage::disk('local')->path($file->stored_path); - $zip = new ZipArchive; - if ($zip->open($storedAbsPath) !== true) { + $extracted = $this->extractProBundle($storedAbsPath); + if ($extracted === null) { continue; } - $proBytes = null; + [$proBytes, $mediaFiles] = $extracted; - for ($i = 0; $i < $zip->numFiles; $i++) { - $entryName = $zip->getNameIndex($i); - if ($entryName === false) { - continue; + foreach ($mediaFiles as $entryName => $entryBytes) { + $mediaName = basename((string) $entryName); + if (! isset($embedded[$mediaName])) { + $embedded[$mediaName] = $entryBytes; } - - 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; @@ -625,6 +610,90 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri return $items; } + /** + * Extract the inner .pro bytes (verbatim) and media files from a .probundle archive. + * + * Uses raw ZipArchive extraction so the original .pro bytes are preserved exactly + * (no protobuf round-trip). ProPresenter-authored bundles use Zip64; Zip64Fixer is + * applied first so those archives open correctly. Normal (non-Zip64) archives pass + * through Zip64Fixer unchanged. Returns null when the bundle has no .pro entry. + * + * @return array{0: string, 1: array}|null [proBytes, mediaFiles] + */ + private function extractProBundle(string $absPath): ?array + { + $rawBytes = @file_get_contents($absPath); + if ($rawBytes === false || $rawBytes === '') { + return null; + } + + try { + $fixedBytes = Zip64Fixer::fix($rawBytes); + } catch (\Throwable) { + // Not Zip64-fixable: fall back to the original bytes. + $fixedBytes = $rawBytes; + } + + $tempPath = tempnam(sys_get_temp_dir(), 'export-probundle-'); + if ($tempPath === false) { + return null; + } + + $zip = new ZipArchive; + $isOpen = false; + + try { + if (@file_put_contents($tempPath, $fixedBytes) === false) { + return null; + } + + if ($zip->open($tempPath) !== true) { + return null; + } + $isOpen = true; + + $proFilename = null; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name !== false && str_ends_with(strtolower($name), '.pro')) { + $proFilename = $name; + break; + } + } + + if ($proFilename === null) { + return null; + } + + $proBytes = $zip->getFromName($proFilename); + if ($proBytes === false || $proBytes === '') { + return null; + } + + $mediaFiles = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false || $name === $proFilename) { + continue; + } + + $contents = $zip->getFromIndex($i); + if ($contents === false) { + continue; + } + + $mediaFiles[$name] = $contents; + } + + return [$proBytes, $mediaFiles]; + } finally { + if ($isOpen) { + $zip->close(); + } + @unlink($tempPath); + } + } + protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void { ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); @@ -684,12 +753,45 @@ private function writeProAndEmbed(string $name, array $slideData, string $tempDi $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; } - private function countSongLabels(\App\Models\Song $song): int + /** + * Number of content slides in the song's default arrangement. + * Content = SongSlide rows belonging to NON-locked sections (locked false/null) + * that have a label. Locked prefix/postfix sections are ignored even if non-empty. + */ + private function countSongContentSlides(\App\Models\Song $song): int { - return $song->arrangements() - ->withCount('arrangementLabels') - ->get() - ->sum('arrangement_labels_count'); + $song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'); + + $defaultArr = $song->arrangements->firstWhere('is_default', true) + ?? $song->arrangements->first(); + + if ($defaultArr === null) { + return 0; + } + + $count = 0; + $seenSectionIds = []; + + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; + + if ($section === null || $section->label === null) { + continue; + } + + if ($section->locked === true) { + continue; + } + + if (in_array($section->id, $seenSectionIds, true)) { + continue; + } + $seenSectionIds[] = $section->id; + + $count += $section->slides->count(); + } + + return $count; } private function backgroundData(?Service $service): ?array diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index eaa8816..23e3359 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -52,13 +52,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) { $song = $agendaItem->serviceSong->song; - $labelCount = $song->arrangements() - ->withCount('arrangementLabels') - ->get() - ->sum('arrangement_labels_count'); - - if ($labelCount === 0) { - throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.'); + if ($this->countSongContentSlides($song) === 0) { + throw new RuntimeException('Lied "'.$song->title.'" übersprungen: keine Inhaltsfolien.'); } $parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service); @@ -119,6 +114,7 @@ private function buildBundleFromSlides( 'media' => $imageFilename, 'format' => 'JPG', 'label' => $slide->original_filename, + 'bundleRelative' => true, ]; if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { @@ -173,6 +169,47 @@ private function buildBundleFromSlides( return $bundlePath; } + /** + * Number of content slides in the song's default arrangement. + * Content = SongSlide rows belonging to NON-locked sections (locked false/null) + * that have a label. Locked prefix/postfix sections are ignored even if non-empty. + */ + private function countSongContentSlides(\App\Models\Song $song): int + { + $song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'); + + $defaultArr = $song->arrangements->firstWhere('is_default', true) + ?? $song->arrangements->first(); + + if ($defaultArr === null) { + return 0; + } + + $count = 0; + $seenSectionIds = []; + + foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) { + $section = $arrangementSection->section; + + if ($section === null || $section->label === null) { + continue; + } + + if ($section->locked === true) { + continue; + } + + if (in_array($section->id, $seenSectionIds, true)) { + continue; + } + $seenSectionIds[] = $section->id; + + $count += $section->slides->count(); + } + + return $count; + } + private function backgroundData(?Service $service): ?array { if ($this->backgroundSourcePath($service) === null) { diff --git a/app/Services/SongPrefixPostfixService.php b/app/Services/SongPrefixPostfixService.php index 5be90da..236775c 100644 --- a/app/Services/SongPrefixPostfixService.php +++ b/app/Services/SongPrefixPostfixService.php @@ -18,8 +18,8 @@ public function ensure(Song $song): void $prefixLabelId = $this->resolvePrefixLabelId(); $postfixLabelId = $this->resolvePostfixLabelId(); - $prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0); - $postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX); + $prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0, 'prefix'); + $postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX, 'postfix'); $arrangement = $this->resolveDefaultArrangement($song); @@ -64,7 +64,10 @@ private function resolvePostfixLabelId(): int return $label->id; } - private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection + /** + * @param 'prefix'|'postfix' $kind + */ + private function ensureLockedSection(Song $song, int $labelId, int $orderHint, string $kind): SongSection { $section = SongSection::firstOrCreate( ['song_id' => $song->id, 'label_id' => $labelId], @@ -74,9 +77,43 @@ private function ensureLockedSection(Song $song, int $labelId, int $orderHint): $section->update(['locked' => true]); $section->slides()->delete(); + $textContent = $kind === 'prefix' + ? $this->buildCopyrightText($song) + : ''; + + $section->slides()->create([ + 'order' => 1, + 'text_content' => $textContent, + ]); + return $section; } + private function buildCopyrightText(Song $song): string + { + $title = trim((string) ($song->title ?? $song->name ?? '')); + + $lines = []; + + if ($title !== '') { + $lines[] = $title; + } + + if (trim((string) $song->author) !== '') { + $lines[] = trim((string) $song->author); + } + + if (trim((string) $song->copyright_text) !== '') { + $lines[] = trim((string) $song->copyright_text); + } + + if (trim((string) $song->ccli_id) !== '') { + $lines[] = 'CCLI-Liednr. '.trim((string) $song->ccli_id); + } + + return implode("\n", $lines); + } + private function resolveDefaultArrangement(Song $song): SongArrangement { return SongArrangement::firstOrCreate( diff --git a/resources/js/Components/SongAgendaItem.vue b/resources/js/Components/SongAgendaItem.vue index b349ec4..2c7a26b 100644 --- a/resources/js/Components/SongAgendaItem.vue +++ b/resources/js/Components/SongAgendaItem.vue @@ -1,6 +1,6 @@