diff --git a/app/Http/Controllers/ExportProFileController.php b/app/Http/Controllers/ExportProFileController.php new file mode 100644 index 0000000..9dd8178 --- /dev/null +++ b/app/Http/Controllers/ExportProFileController.php @@ -0,0 +1,57 @@ +validate([ + 'type' => ['required', Rule::in(['prefix', 'postfix'])], + 'files' => ['required', 'array'], + 'files.*' => ['required', 'file', 'max:10240', 'extensions:pro'], + ]); + + $type = $request->input('type'); + $created = []; + + foreach ($request->file('files') as $file) { + $storedPath = $file->store('export-pro-files/'.$type, 'local'); + $maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0; + + $record = ExportProFile::create([ + 'type' => $type, + 'original_name' => $file->getClientOriginalName(), + 'stored_path' => $storedPath, + 'order' => $maxOrder + 1, + ]); + + $created[] = [ + 'id' => $record->id, + 'original_name' => $record->original_name, + 'order' => $record->order, + ]; + } + + return response()->json([ + 'files' => $created, + 'message' => count($created).' Datei(en) erfolgreich hochgeladen.', + ], 201); + } + + public function destroy(ExportProFile $exportProFile): JsonResponse + { + Storage::disk('local')->delete($exportProFile->stored_path); + $exportProFile->delete(); + + return response()->json([ + 'message' => 'Datei erfolgreich gelöscht.', + ]); + } +} diff --git a/app/Http/Controllers/ProFileController.php b/app/Http/Controllers/ProFileController.php index c899c7f..3f9846c 100644 --- a/app/Http/Controllers/ProFileController.php +++ b/app/Http/Controllers/ProFileController.php @@ -27,7 +27,7 @@ public function importPro(Request $request): JsonResponse } try { - $service = new ProImportService; + $service = app(ProImportService::class); $songs = $service->import($file); return response()->json([ diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index c160c51..adc02e9 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -131,11 +131,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image $service->load([ 'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs.song', + 'serviceSongs.song.sections.label', + 'serviceSongs.song.sections.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label', 'serviceSongs.arrangement', 'slides', 'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems.slides', + 'agendaItems.serviceSong.song.sections.label', + 'agendaItems.serviceSong.song.sections.slides', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides', 'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label', 'agendaItems.serviceSong.arrangement.arrangementSections.section.label', @@ -227,6 +231,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image ? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns))) : false; + // Inject `groups` into the nested song so the frontend ArrangementDialog + // can resolve all sections (including those not in the default arrangement). + if (isset($arr['service_song']['song'])) { + $song = $item->serviceSong?->song; + if ($song !== null) { + $arr['service_song']['song']['groups'] = $this->collectSongLabels($song)->toArray(); + } + } + return $arr; }, $filteredItems); @@ -283,6 +296,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image 'background_is_own' => $backgroundIsOwn, 'moderator_name' => $service->moderator_name, 'preacher_name_override' => $service->preacher_name_override, + 'worship_leader_name' => app(\App\Services\NameTagResolver::class)->worshipLeaderFor($service), ], 'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [ 'id' => $ss->id, @@ -438,6 +452,24 @@ public function download(Service $service): JsonResponse|BinaryFileResponse } } + public function downloadPreview(Service $service): JsonResponse|BinaryFileResponse + { + try { + $playlistService = app(\App\Services\PlaylistExportService::class); + $result = $playlistService->generatePlaylist($service, true); + + $response = response()->download($result['path'], $result['filename']); + + if ($result['skipped'] > 0) { + $response->headers->set('X-Skipped-Songs', (string) $result['skipped']); + } + + return $response->deleteFileAfterSend(false); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + } + public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse { $request->merge(['blockType' => $blockType]); @@ -481,23 +513,28 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt private function collectSongLabels(Song $song): \Illuminate\Support\Collection { - $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); - - if ($defaultArr === null) { - return collect(); - } - - return $defaultArr->arrangementSections - ->sortBy('order') - ->values() - ->map(fn ($arrangementSection) => [ - 'id' => $arrangementSection->section?->label?->id, - 'section_id' => $arrangementSection->section?->id, - 'name' => $arrangementSection->section?->label?->name, - 'color' => $arrangementSection->section?->label?->color, - 'order' => $arrangementSection->order, + // Build availableGroups from ALL song sections (not just the default arrangement), + // so every arrangement's referenced sections resolve to real slides on the frontend. + return $song->sections + ->filter(fn ($section) => $section->label !== null) + ->map(fn ($section) => [ + 'id' => $section->label->id, + 'section_id' => $section->id, + 'name' => $section->label->name, + 'color' => $section->label->color, + 'order' => $section->order, + 'locked' => (bool) ($section->locked ?? false), + 'slides' => $section->slides + ->sortBy('order') + ->values() + ->map(fn ($slide) => [ + 'id' => $slide->id, + 'order' => $slide->order, + 'text_content' => $slide->text_content, + 'text_content_translated' => $slide->text_content_translated, + 'notes' => $slide->notes ?? null, + ])->toArray(), ]) - ->filter(fn ($group) => $group['id'] !== null) ->values(); } } diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index d5bb21b..6ff7ff2 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\ExportProFile; use App\Models\Label; use App\Models\Macro; use App\Models\MacroAssignment; @@ -27,10 +28,24 @@ class SettingsController extends Controller 'namenseinblender_macro_uuid', 'namenseinblender_macro_collection_name', 'namenseinblender_macro_collection_uuid', + 'namenseinblender_macro_id', + 'song_prefix_label_id', + 'song_postfix_label_id', ]; public function index(): Response { + $copyright = Label::firstOrCreate(['name' => 'COPYRIGHT'], ['color' => '#24B34C']); + $blank = Label::firstOrCreate(['name' => 'BLANK'], ['color' => '#000000']); + + if (Setting::get('song_prefix_label_id') === null) { + Setting::set('song_prefix_label_id', (string) $copyright->id); + } + + if (Setting::get('song_postfix_label_id') === null) { + Setting::set('song_postfix_label_id', (string) $blank->id); + } + $settings = []; foreach (self::AGENDA_KEYS as $key) { $settings[$key] = Setting::get($key); @@ -50,6 +65,10 @@ public function index(): Response 'at' => Setting::get('labels_last_imported_at'), 'filename' => Setting::get('labels_last_imported_filename'), ], + 'export_pro_files' => [ + 'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']), + 'postfix' => ExportProFile::postfix()->orderBy('order')->get(['id', 'original_name', 'order']), + ], ]); } diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php index 32772c3..c8f3cc9 100644 --- a/app/Http/Controllers/SongController.php +++ b/app/Http/Controllers/SongController.php @@ -135,6 +135,7 @@ public function formatSongDetail(Song $song): array 'name' => $arrangementSection->section?->label?->name, 'color' => $arrangementSection->section?->label?->color, 'order' => $arrangementSection->order, + 'locked' => (bool) ($arrangementSection->section?->locked ?? false), 'slides' => $arrangementSection->section ? $arrangementSection->section->slides ->sortBy('order') diff --git a/app/Http/Controllers/SongSectionController.php b/app/Http/Controllers/SongSectionController.php index bd0931c..0718cd5 100644 --- a/app/Http/Controllers/SongSectionController.php +++ b/app/Http/Controllers/SongSectionController.php @@ -77,11 +77,17 @@ public function update(Request $request, Song $song, SongSection $section): Json return response()->json(['message' => 'Sektion nicht gefunden.'], 404); } + if ($section->locked) { + return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht bearbeitet werden.'], 422); + } + $data = $request->validate([ 'slides' => ['required', 'array'], 'slides.*.text_content' => ['required', 'string'], 'slides.*.text_content_translated' => ['nullable', 'string'], 'order' => ['sometimes', 'integer'], + 'label_name' => ['sometimes', 'string', 'max:255'], + 'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], ], $this->validationMessages()); $responseSong = DB::transaction(function () use ($song, $section, $data): Song { @@ -89,6 +95,17 @@ public function update(Request $request, Song $song, SongSection $section): Json $section->update(['order' => $data['order']]); } + if (array_key_exists('label_name', $data) && trim($data['label_name']) !== '') { + $normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']); + + $label = Label::firstOrCreate( + ['name' => $normalizedLabelName], + ['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR], + ); + + $section->update(['label_id' => $label->id]); + } + $this->replaceSlides($section, $data['slides']); $this->recomputeHasTranslation($song); @@ -107,6 +124,10 @@ public function destroy(Song $song, SongSection $section): JsonResponse return response()->json(['message' => 'Sektion nicht gefunden.'], 404); } + if ($section->locked) { + return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht gelöscht werden.'], 422); + } + $responseSong = DB::transaction(function () use ($song, $section): Song { SongArrangementSection::query() ->where('song_section_id', $section->id) diff --git a/app/Models/ExportProFile.php b/app/Models/ExportProFile.php new file mode 100644 index 0000000..250a000 --- /dev/null +++ b/app/Models/ExportProFile.php @@ -0,0 +1,33 @@ + 'integer', + ]; + } + + public function scopePrefix(Builder $query): Builder + { + return $query->where('type', 'prefix'); + } + + public function scopePostfix(Builder $query): Builder + { + return $query->where('type', 'postfix'); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 63c4fc2..24f997e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Services\AgendaMatcherService; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -64,6 +66,14 @@ public function agendaItems(): HasMany return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order'); } + /** + * Scope: nächster bevorstehender Gottesdienst (heute oder später), aufsteigend sortiert. + */ + public function scopeNextUpcoming(Builder $query): Builder + { + return $query->whereDate('date', '>=', Carbon::today())->orderBy('date'); + } + /** * Check finalization prerequisites and return warnings. * diff --git a/app/Models/SongSection.php b/app/Models/SongSection.php index 84eceda..d0d1f62 100644 --- a/app/Models/SongSection.php +++ b/app/Models/SongSection.php @@ -15,12 +15,14 @@ class SongSection extends Model 'song_id', 'label_id', 'order', + 'locked', ]; protected function casts(): array { return [ 'order' => 'integer', + 'locked' => 'boolean', ]; } diff --git a/app/Services/CcliImportService.php b/app/Services/CcliImportService.php index b4dadc1..ccb2167 100644 --- a/app/Services/CcliImportService.php +++ b/app/Services/CcliImportService.php @@ -38,6 +38,7 @@ final class CcliImportService public function __construct( private readonly CcliPasteParser $parser, + private readonly SongPrefixPostfixService $songPrefixPostfixService, ) {} /** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */ @@ -128,6 +129,8 @@ public function import(string $rawText, ?string $sourceUrl = null): array ]); } + $this->songPrefixPostfixService->ensure($song); + $song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); ApiRequestLog::create([ diff --git a/app/Services/NameTagResolver.php b/app/Services/NameTagResolver.php index ed582a3..dbb66db 100644 --- a/app/Services/NameTagResolver.php +++ b/app/Services/NameTagResolver.php @@ -30,6 +30,18 @@ public function moderatorFor(Service $service): ?string return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null; } + 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; + } + public function preacherFor(Service $service): ?string { $override = $this->filledString($service->preacher_name_override); @@ -99,6 +111,15 @@ private function nameFromResponsiblePerson(mixed $person): ?string return $fullName === '' ? null : $fullName; } + private function isWorshipItem(ServiceAgendaItem $item): bool + { + $title = Str::lower($item->title); + $type = Str::lower($item->type ?? ''); + + return str_contains($title, 'lobpreis') + || str_contains($type, 'lobpreis'); + } + private function isSermonItem(ServiceAgendaItem $item): bool { $configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching')); diff --git a/app/Services/NameTagSlideBuilder.php b/app/Services/NameTagSlideBuilder.php index 658ac96..270f06f 100644 --- a/app/Services/NameTagSlideBuilder.php +++ b/app/Services/NameTagSlideBuilder.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Macro; use App\Models\Setting; class NameTagSlideBuilder @@ -18,6 +19,26 @@ public function buildPreacherSlide(string $name): ?array public function build(string $name, string $title): ?array { + $macroId = Setting::get('namenseinblender_macro_id'); + + if ($macroId !== null && trim($macroId) !== '') { + $macro = Macro::with('collections')->find((int) $macroId); + + if ($macro !== null) { + $collection = $macro->collections->first(); + + return [ + 'text' => $name."\n".$title, + 'macro' => [ + 'name' => $macro->name, + 'uuid' => $macro->uuid, + 'collectionName' => $collection?->name ?? '--MAIN--', + 'collectionUuid' => $collection?->uuid ?? null, + ], + ]; + } + } + $macroName = Setting::get('namenseinblender_macro_name'); if ($macroName === null || trim($macroName) === '') { diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index fb59e89..6d00534 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\ExportProFile; use App\Models\Service; use App\Models\ServiceAgendaItem; use App\Models\Setting; @@ -14,7 +15,7 @@ class PlaylistExportService { /** @return array{path: string, filename: string, skipped: int} */ - public function generatePlaylist(Service $service): array + public function generatePlaylist(Service $service, bool $preview = false): array { $agendaItems = ServiceAgendaItem::where('service_id', $service->id) ->where('is_before_event', false) @@ -28,17 +29,17 @@ public function generatePlaylist(Service $service): array ->get(); if ($agendaItems->isEmpty()) { - return $this->generatePlaylistLegacy($service); + return $this->generatePlaylistLegacy($service, $preview); } - return $this->generatePlaylistFromAgenda($service, $agendaItems); + return $this->generatePlaylistFromAgenda($service, $agendaItems, $preview); } /** * @param Collection $agendaItems * @return array{path: string, filename: string, skipped: int, temp_dir: string} */ - private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems): array + private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array { $informationSlides = Slide::where('type', 'information') ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date)) @@ -65,6 +66,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $moderatorSlideData = $this->buildModeratorSlideData($service); $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; + $realContentEmitted = false; + $keyvisualFallbackEmitted = false; + foreach ($agendaItems as $item) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { $this->writeProAndEmbed( @@ -119,6 +123,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda 'name' => $song->title, 'path' => $proFilename, ]; + + $realContentEmitted = true; } else { $skippedUnmatched++; } @@ -132,6 +138,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles); } + $countBefore = count($playlistItems); $label = $item->title ?: 'Folien'; $this->addSlidesFromCollection( $item->slides, @@ -144,17 +151,31 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $this->backgroundPartTypeForAgendaItem($item), ); + if (count($playlistItems) > $countBefore) { + $realContentEmitted = true; + } + continue; } if (! $this->isNameTagAgendaItem($item)) { - $this->addKeyVisualFallbackPresentation( - $item, - $service, - $tempDir, - $playlistItems, - $embeddedFiles, - ); + if ($realContentEmitted && ! $keyvisualFallbackEmitted) { + $this->addKeyVisualFallbackPresentation( + $item, + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + $keyvisualFallbackEmitted = true; + } else { + $this->addHeadlinePresentation( + $item, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + } } } @@ -180,9 +201,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } + $this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir); + $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); - $playlistName = $service->title.' - '.$dateFormatted; - $outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist'; + $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); + + if ($preview) { + $exportStamp = now()->format('Y-m-d_H-i'); + $playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')'; + $outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist'; + } else { + $playlistName = $dateFormatted.' '.$service->title; + $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist'; + } + $outputPath = $tempDir.'/'.$outputFilename; $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); @@ -201,7 +233,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda * * @return array{path: string, filename: string, skipped: int, temp_dir: string} */ - private function generatePlaylistLegacy(Service $service): array + private function generatePlaylistLegacy(Service $service, bool $preview = false): array { $service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label'); @@ -277,9 +309,20 @@ private function generatePlaylistLegacy(Service $service): array throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); } + $this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir); + $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); - $playlistName = $service->title.' - '.$dateFormatted; - $outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist'; + $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); + + if ($preview) { + $exportStamp = now()->format('Y-m-d_H-i'); + $playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')'; + $outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist'; + } else { + $playlistName = $dateFormatted.' '.$service->title; + $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist'; + } + $outputPath = $tempDir.'/'.$outputFilename; $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles); @@ -471,6 +514,99 @@ private function addKeyVisualFallbackPresentation( ]; } + private function addHeadlinePresentation( + 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, + ]; + } + + private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, string $tempDir): void + { + $prefixFiles = ExportProFile::prefix()->orderBy('order')->get(); + $postfixFiles = ExportProFile::postfix()->orderBy('order')->get(); + + $prefixItems = []; + $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, + ]; + } + + $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); + $embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded); + } + protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void { ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); diff --git a/app/Services/ProImportService.php b/app/Services/ProImportService.php index 298676e..43780f9 100644 --- a/app/Services/ProImportService.php +++ b/app/Services/ProImportService.php @@ -16,6 +16,10 @@ class ProImportService { + public function __construct( + private readonly SongPrefixPostfixService $songPrefixPostfixService, + ) {} + /** @return Song[] */ public function import(UploadedFile $file): array { @@ -174,6 +178,8 @@ private function upsertSong(ProSong $proSong): Song } } + $this->songPrefixPostfixService->ensure($song); + return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); } diff --git a/app/Services/SongMatchingService.php b/app/Services/SongMatchingService.php index 9075897..62f2de6 100644 --- a/app/Services/SongMatchingService.php +++ b/app/Services/SongMatchingService.php @@ -11,6 +11,10 @@ class SongMatchingService { + public function __construct( + private readonly SongPrefixPostfixService $songPrefixPostfixService, + ) {} + public function autoMatch(ServiceSong $serviceSong): bool { if ($serviceSong->song_id !== null) { @@ -76,6 +80,8 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void } $serviceSong->update($updateData); + + $this->songPrefixPostfixService->ensure($song); } /** diff --git a/app/Services/SongPrefixPostfixService.php b/app/Services/SongPrefixPostfixService.php new file mode 100644 index 0000000..5be90da --- /dev/null +++ b/app/Services/SongPrefixPostfixService.php @@ -0,0 +1,133 @@ +resolvePrefixLabelId(); + $postfixLabelId = $this->resolvePostfixLabelId(); + + $prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0); + $postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX); + + $arrangement = $this->resolveDefaultArrangement($song); + + $this->ensureSectionInArrangement($arrangement, $prefixSection, 'first'); + $this->ensureSectionInArrangement($arrangement, $postfixSection, 'last'); + }); + } + + private function resolvePrefixLabelId(): int + { + $id = Setting::get('song_prefix_label_id'); + + if ($id !== null && $id !== '') { + return (int) $id; + } + + $label = Label::firstOrCreate( + ['name' => 'COPYRIGHT'], + ['color' => '#24B34C'], + ); + + Setting::set('song_prefix_label_id', (string) $label->id); + + return $label->id; + } + + private function resolvePostfixLabelId(): int + { + $id = Setting::get('song_postfix_label_id'); + + if ($id !== null && $id !== '') { + return (int) $id; + } + + $label = Label::firstOrCreate( + ['name' => 'BLANK'], + ['color' => '#000000'], + ); + + Setting::set('song_postfix_label_id', (string) $label->id); + + return $label->id; + } + + private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection + { + $section = SongSection::firstOrCreate( + ['song_id' => $song->id, 'label_id' => $labelId], + ['order' => $orderHint, 'locked' => true], + ); + + $section->update(['locked' => true]); + $section->slides()->delete(); + + return $section; + } + + private function resolveDefaultArrangement(Song $song): SongArrangement + { + return SongArrangement::firstOrCreate( + ['song_id' => $song->id, 'name' => 'normal'], + ['is_default' => true], + ); + } + + private function ensureSectionInArrangement( + SongArrangement $arrangement, + SongSection $section, + string $position, + ): void { + $existing = SongArrangementSection::where('song_arrangement_id', $arrangement->id) + ->where('song_section_id', $section->id) + ->first(); + + if ($position === 'first') { + if ($existing === null) { + SongArrangementSection::where('song_arrangement_id', $arrangement->id) + ->increment('order'); + + SongArrangementSection::create([ + 'song_arrangement_id' => $arrangement->id, + 'song_section_id' => $section->id, + 'order' => 0, + ]); + } else { + if ($existing->order !== 0) { + SongArrangementSection::where('song_arrangement_id', $arrangement->id) + ->where('id', '!=', $existing->id) + ->where('order', '<', $existing->order) + ->increment('order'); + + $existing->update(['order' => 0]); + } + } + } else { + $maxOrder = SongArrangementSection::where('song_arrangement_id', $arrangement->id) + ->where('song_section_id', '!=', $section->id) + ->max('order') ?? 0; + + if ($existing === null) { + SongArrangementSection::create([ + 'song_arrangement_id' => $arrangement->id, + 'song_section_id' => $section->id, + 'order' => $maxOrder + 1, + ]); + } else { + $existing->update(['order' => $maxOrder + 1]); + } + } + } +} diff --git a/database/migrations/2026_05_31_222211_create_export_pro_files_table.php b/database/migrations/2026_05_31_222211_create_export_pro_files_table.php new file mode 100644 index 0000000..10f5728 --- /dev/null +++ b/database/migrations/2026_05_31_222211_create_export_pro_files_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('type'); + $table->string('original_name'); + $table->string('stored_path'); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('export_pro_files'); + } +}; diff --git a/database/migrations/2026_05_31_223108_add_locked_to_song_sections_table.php b/database/migrations/2026_05_31_223108_add_locked_to_song_sections_table.php new file mode 100644 index 0000000..7f7e73f --- /dev/null +++ b/database/migrations/2026_05_31_223108_add_locked_to_song_sections_table.php @@ -0,0 +1,22 @@ +boolean('locked')->default(false)->after('order'); + }); + } + + public function down(): void + { + Schema::table('song_sections', function (Blueprint $table) { + $table->dropColumn('locked'); + }); + } +}; diff --git a/resources/js/Components/ArrangementDialog.vue b/resources/js/Components/ArrangementDialog.vue index b5a5201..2ebe4a4 100644 --- a/resources/js/Components/ArrangementDialog.vue +++ b/resources/js/Components/ArrangementDialog.vue @@ -40,9 +40,21 @@ const props = defineProps({ type: Array, default: () => [], }, + worshipLeaderName: { + type: String, + default: null, + }, + serviceSongArrangementId: { + type: Number, + default: null, + }, + defaultArrangementId: { + type: Number, + default: null, + }, }) -const emit = defineEmits(['close', 'arrangement-selected']) +const emit = defineEmits(['close', 'arrangement-selected', 'unassigned']) /* ── Song assignment (unmatched songs) ── */ @@ -118,6 +130,19 @@ async function assignSong() { } } +async function unassignSong() { + if (!confirm('Diese Songzuordnung wirklich aufheben?')) return + + try { + await window.axios.post(`/api/service-songs/${props.serviceSongId}/unassign`) + emit('unassigned') + emit('close') + } catch { + // Silently ignore — parent will reload on close + emit('close') + } +} + /* ── State ── */ // Virtual MASTER arrangement — always first, computed from availableGroups @@ -272,6 +297,71 @@ function closeOnEscape(e) { } } +async function selectArrangementForServiceSong(arrangementId) { + try { + await window.axios.patch(`/api/service-songs/${props.serviceSongId}`, { + song_arrangement_id: arrangementId, + }) + currentArrangementId.value = arrangementId + } catch { + // Ignore — user can select manually + } +} + +async function ensureLeaderArrangement() { + if (!props.worshipLeaderName || isUnmatched.value) return + + // Only auto-act if the user hasn't manually chosen an arrangement + const isUnchanged = + props.serviceSongArrangementId === null || + props.serviceSongArrangementId === props.defaultArrangementId + + if (!isUnchanged) return + + const leaderName = props.worshipLeaderName.trim() + if (!leaderName) return + + // Check if an arrangement with this name already exists + const existing = props.arrangements.find( + (a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(), + ) + + if (existing) { + await selectArrangementForServiceSong(existing.id) + return + } + + // Create by cloning the default arrangement + const defaultArr = props.arrangements.find((a) => a.is_default) ?? props.arrangements[0] + if (!defaultArr) return + + try { + pendingAutoSelect.value = true + router.post( + `/arrangements/${defaultArr.id}/clone`, + { name: leaderName }, + { + preserveScroll: true, + onSuccess: async () => { + router.reload({ + preserveScroll: true, + onSuccess: async () => { + const created = props.arrangements.find( + (a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(), + ) + if (created) { + await selectArrangementForServiceSong(created.id) + } + }, + }) + }, + }, + ) + } catch { + // Ignore + } +} + onMounted(() => { document.addEventListener('keydown', closeOnEscape) document.addEventListener('click', onBodyClick) @@ -279,6 +369,9 @@ onMounted(() => { // For unmatched songs: show search results immediately (prefilled with the song name). if (isUnmatched.value) { dropdownOpen.value = true + } else { + // For matched songs: auto-create/select worship-leader arrangement if un-changed. + ensureLeaderArrangement() } }) @@ -289,8 +382,13 @@ onUnmounted(() => { /* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */ +function leaderPrefix() { + return props.worshipLeaderName?.trim() ? `${props.worshipLeaderName.trim()} - ` : '' +} + function createArrangement() { - const name = window.prompt('Name für das neue Arrangement:') + const defaultName = leaderPrefix() + const name = window.prompt('Name für das neue Arrangement:', defaultName) if (!name?.trim()) return pendingAutoSelect.value = true router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { @@ -306,7 +404,8 @@ function cloneArrangement() { // Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order) if (isMasterSelected.value) { - const name = window.prompt('Name für das geklonte Arrangement:', 'MASTER Kopie') + const defaultName = leaderPrefix() || 'MASTER Kopie' + const name = window.prompt('Name für das geklonte Arrangement:', defaultName) if (!name?.trim()) return pendingAutoSelect.value = true router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, { @@ -318,7 +417,10 @@ function cloneArrangement() { return } - const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`) + const defaultName = leaderPrefix() + ? `${leaderPrefix()}${currentArrangement.value?.name ?? ''}` + : `${currentArrangement.value?.name ?? ''} Kopie` + const name = window.prompt('Name für das geklonte Arrangement:', defaultName) if (!name?.trim()) return pendingAutoSelect.value = true router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, { @@ -497,6 +599,15 @@ function closeOnBackdrop(e) { Löschen + + -
+
+ + + + Gesperrt – wird automatisch verwaltet +
+ +
@@ -884,8 +1023,6 @@ onUnmounted(() => { rows="6" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" placeholder="Übersetzter Folientext…" - @input="onSectionInput(group.section_id ?? group.id)" - @blur="onSectionBlur(group.section_id ?? group.id)" />
@@ -907,6 +1044,31 @@ onUnmounted(() => { /> + + +
+ + + +
diff --git a/resources/js/Pages/Services/Edit.vue b/resources/js/Pages/Services/Edit.vue index 4e2ff5b..1433c6b 100644 --- a/resources/js/Pages/Services/Edit.vue +++ b/resources/js/Pages/Services/Edit.vue @@ -315,6 +315,38 @@ async function downloadService() { showToast('Fehler beim Herunterladen.', 'warning') } } + +async function downloadPreview() { + try { + const response = await fetch(route('services.download-preview', props.service.id), { + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + }, + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + showToast(data.message || 'Fehler beim Herunterladen der Vorschau.', 'warning') + return + } + + const disposition = response.headers.get('content-disposition') + const filenameMatch = disposition?.match(/filename="?([^"]+)"?/) + const filename = filenameMatch?.[1] || 'vorschau.proplaylist' + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + + showToast('Vorschau wurde heruntergeladen.', 'success') + } catch { + showToast('Fehler beim Herunterladen der Vorschau.', 'warning') + } +}