From e33418f716c241fdc72f2812d065b4e5dee0485f Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 1 Jun 2026 08:56:20 +0200 Subject: [PATCH] feat: song pre/postfix, settings overhaul, export & schedule fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves a batch of bugs and feature requests across songs, services, settings and export: Songs & sections - Every song now carries permanent, empty, locked PREFIX (COPYRIGHT) and POSTFIX (BLANK) sections, deduplicated on import; locked sections cannot be edited or deleted via UI or API. - Song edit modal: explicit Speichern/Schließen with dirty-tracking, editable section headline (combobox + custom values), and a fix for the 419 CSRF errors after CCLI "Importieren & Bearbeiten" (token read fresh per request). - CCLI bookmarklet "Importieren & Bearbeiten" now opens the edit dialog. Service schedule & arrangements - Fixed assigned songs showing no sections (slides loaded for all arrangements, not just the default). - Added "Song entfernen / neu zuordnen" to reassign an assigned song. - Worship-leader arrangement is created/selected lazily when the arrangement dialog opens (only when not user-overridden); the leader is resolved from the "Lobpreis" agenda item, and manual create/clone names are prefixed with the leader name. Navigation - "/" redirects to the next upcoming service's edit page (or the list). - Service titles link to the edit page. Settings - Renamed "Makro-Import"/"Label-Import" menu items; fixed drag-and-drop imports (were downloading the dropped file); added label-import hint; made the panel scrollable. - Nametag now uses a single MacroPicker; added song prefix/postfix label defaults (COPYRIGHT #24B34C / BLANK #000000); new "Export-Dateien" menu to upload prefix/postfix .pro files added to every export. Export - Filenames/playlist names are date-first ("YYYY-MM-DD "). - Keyvisual slide only for the first content-less item after real content; all other content-less items render as headlines. - New "Vorschau herunterladen" for non-finalized services (filename and import name prefixed "Vorschau" with export timestamp). - Uploaded prefix/postfix .pro files wrap every export. Tests updated to the new behavior; full suite green (569 passed). --- .../Controllers/ExportProFileController.php | 57 +++ app/Http/Controllers/ProFileController.php | 2 +- app/Http/Controllers/ServiceController.php | 69 +++- app/Http/Controllers/SettingsController.php | 19 + app/Http/Controllers/SongController.php | 1 + .../Controllers/SongSectionController.php | 21 + app/Models/ExportProFile.php | 33 ++ app/Models/Service.php | 10 + app/Models/SongSection.php | 2 + app/Services/CcliImportService.php | 3 + app/Services/NameTagResolver.php | 21 + app/Services/NameTagSlideBuilder.php | 21 + app/Services/PlaylistExportService.php | 168 +++++++- app/Services/ProImportService.php | 6 + app/Services/SongMatchingService.php | 6 + app/Services/SongPrefixPostfixService.php | 133 +++++++ ...1_222211_create_export_pro_files_table.php | 25 ++ ...3108_add_locked_to_song_sections_table.php | 22 ++ resources/js/Components/ArrangementDialog.vue | 119 +++++- resources/js/Components/CcliPasteDialog.vue | 4 + resources/js/Components/SongEditModal.vue | 374 +++++++++++++----- resources/js/Pages/Services/Edit.vue | 47 +++ resources/js/Pages/Services/Index.vue | 44 ++- resources/js/Pages/Settings.vue | 159 ++++---- .../js/Pages/Settings/ExportProFiles.vue | 226 +++++++++++ resources/js/Pages/Settings/LabelImport.vue | 34 +- resources/js/Pages/Settings/MacroImport.vue | 29 +- .../js/Pages/Songs/ImportFromCcliPaste.vue | 18 +- routes/web.php | 13 +- tests/Feature/CcliImportServiceTest.php | 4 +- tests/Feature/HomeTest.php | 19 +- tests/Feature/KeyVisualFallbackTest.php | 45 ++- tests/Feature/NameTagResolverTest.php | 39 ++ tests/Feature/PlaylistExportTest.php | 10 +- tests/Feature/ProFileImportTest.php | 4 +- tests/Feature/SongCcliMetadataTest.php | 5 +- 36 files changed, 1559 insertions(+), 253 deletions(-) create mode 100644 app/Http/Controllers/ExportProFileController.php create mode 100644 app/Models/ExportProFile.php create mode 100644 app/Services/SongPrefixPostfixService.php create mode 100644 database/migrations/2026_05_31_222211_create_export_pro_files_table.php create mode 100644 database/migrations/2026_05_31_223108_add_locked_to_song_sections_table.php create mode 100644 resources/js/Pages/Settings/ExportProFiles.vue 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 @@ +<?php + +namespace App\Http\Controllers; + +use App\Models\ExportProFile; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\Rule; + +class ExportProFileController extends Controller +{ + public function store(Request $request): JsonResponse + { + $request->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 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; + +class ExportProFile extends Model +{ + protected $fillable = [ + 'type', + 'original_name', + 'stored_path', + 'order', + ]; + + protected function casts(): array + { + return [ + 'order' => '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<int, ServiceAgendaItem> $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 @@ +<?php + +namespace App\Services; + +use App\Models\Label; +use App\Models\Setting; +use App\Models\Song; +use App\Models\SongArrangement; +use App\Models\SongArrangementSection; +use App\Models\SongSection; +use Illuminate\Support\Facades\DB; + +class SongPrefixPostfixService +{ + public function ensure(Song $song): void + { + DB::transaction(function () use ($song): void { + $prefixLabelId = $this->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 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('export_pro_files', function (Blueprint $table) { + $table->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 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('song_sections', function (Blueprint $table) { + $table->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 </button> + <button + data-testid="arrangement-unassign-button" + type="button" + class="rounded-md border border-orange-300 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 shadow hover:bg-orange-100" + @click="unassignSong" + > + Song entfernen / neu zuordnen + </button> + <button data-testid="arrangement-dialog-close-btn" type="button" diff --git a/resources/js/Components/CcliPasteDialog.vue b/resources/js/Components/CcliPasteDialog.vue index fe88682..3d66500 100644 --- a/resources/js/Components/CcliPasteDialog.vue +++ b/resources/js/Components/CcliPasteDialog.vue @@ -8,6 +8,7 @@ const props = defineProps({ serviceSongId: { type: Number, default: null }, pairWithSongId: { type: Number, default: null }, prefilledText: { type: String, default: null }, + autoEdit: { type: Boolean, default: false }, }) const emit = defineEmits(['close', 'imported', 'paired', 'edit-song']) @@ -47,6 +48,9 @@ async function doPreview() { error.value = data.message || 'Fehler beim Verarbeiten des Textes.' } else { preview.value = data + if (props.autoEdit && (props.mode === 'songdb' || props.mode === 'service-form')) { + await doImport('edit') + } } } catch { error.value = 'Netzwerkfehler. Bitte versuche es erneut.' diff --git a/resources/js/Components/SongEditModal.vue b/resources/js/Components/SongEditModal.vue index bdd50a7..65c0438 100644 --- a/resources/js/Components/SongEditModal.vue +++ b/resources/js/Components/SongEditModal.vue @@ -1,6 +1,5 @@ <script setup> import { computed, ref, watch, onMounted, onUnmounted } from 'vue' -import { useDebounceFn } from '@vueuse/core' import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue' const props = defineProps({ @@ -22,6 +21,7 @@ const loading = ref(false) const error = ref(null) const songData = ref(null) const sectionDrafts = ref({}) +const sectionLabels = ref({}) const title = ref('') const ccliId = ref('') @@ -30,11 +30,63 @@ const showAddSectionForm = ref(false) const newSectionLabel = ref('') const newSectionText = ref('') const sectionLabelDropdownOpen = ref(false) +const headlineDropdownFor = ref(null) const saving = ref(false) const saved = ref(false) let savedTimeout = null -const sectionSaveDebouncers = new Map() + +/* ── Pristine snapshot for dirty tracking ── */ + +const pristine = ref(null) + +function snapshotPristine() { + pristine.value = { + title: title.value, + ccliId: ccliId.value, + copyrightText: copyrightText.value, + sections: JSON.parse(JSON.stringify(sectionDrafts.value)), + labels: { ...sectionLabels.value }, + } +} + +const isDirty = computed(() => { + if (!pristine.value) return false + + if ( + title.value !== pristine.value.title || + ccliId.value !== pristine.value.ccliId || + copyrightText.value !== pristine.value.copyrightText + ) { + return true + } + + const keys = new Set([ + ...Object.keys(pristine.value.sections), + ...Object.keys(sectionDrafts.value), + ]) + + for (const key of keys) { + const before = pristine.value.sections[key] ?? { text: '', translated: '' } + const now = sectionDrafts.value[key] ?? { text: '', translated: '' } + + if ((before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')) { + return true + } + + if ((pristine.value.labels[key] ?? '') !== (sectionLabels.value[key] ?? '')) { + return true + } + } + + return false +}) + +/* ── CSRF (read fresh on every request to avoid stale token after CCLI import) ── */ + +function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content || '' +} /* ── Save status ── */ @@ -72,15 +124,19 @@ function sectionKey(group) { function setSectionDrafts(data) { const drafts = {} + const labels = {} ;(data?.groups ?? []).forEach((group) => { - drafts[sectionKey(group)] = { + const key = sectionKey(group) + drafts[key] = { text: slidesToText(group.slides, 'text_content'), translated: slidesToText(group.slides, 'text_content_translated'), } + labels[key] = group.name ?? '' }) sectionDrafts.value = drafts + sectionLabels.value = labels } function draftFor(group) { @@ -119,6 +175,8 @@ const fetchSong = async () => { title.value = json.data.title ?? '' ccliId.value = json.data.ccli_id ?? '' copyrightText.value = json.data.copyright_text ?? '' + + snapshotPristine() } catch (e) { error.value = e.message } finally { @@ -136,67 +194,119 @@ watch( if (!isVisible) { songData.value = null sectionDrafts.value = {} + sectionLabels.value = {} + pristine.value = null error.value = null showAddSectionForm.value = false newSectionLabel.value = '' newSectionText.value = '' + headlineDropdownFor.value = null } }, ) -/* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */ +/* ── Save metadata ── */ -const performSave = async (data) => { - if (!props.songId) return +const performSaveMetadata = async () => { + const response = await fetch(`/api/songs/${props.songId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': getCsrfToken(), + }, + credentials: 'same-origin', + body: JSON.stringify({ + title: title.value, + ccli_id: ccliId.value || null, + copyright_text: copyrightText.value || null, + }), + }) + + if (!response.ok) { + throw new Error('Speichern fehlgeschlagen') + } +} + +/* ── Save all changes (manual Save button) ── */ + +async function saveAll() { + if (!props.songId || !isDirty.value || saving.value) return startSaving() try { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' + const metadataDirty = + title.value !== pristine.value.title || + ccliId.value !== pristine.value.ccliId || + copyrightText.value !== pristine.value.copyrightText - const response = await fetch(`/api/songs/${props.songId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': csrfToken, - }, - credentials: 'same-origin', - body: JSON.stringify(data), - }) - - if (!response.ok) { - throw new Error('Speichern fehlgeschlagen') + if (metadataDirty) { + await performSaveMetadata() } - finishSaving() + for (const group of songData.value?.groups ?? []) { + if (group.locked) continue + const key = sectionKey(group) + const before = pristine.value.sections[key] ?? { text: '', translated: '' } + const now = sectionDrafts.value[key] ?? { text: '', translated: '' } + const labelBefore = pristine.value.labels[key] ?? '' + const labelNow = sectionLabels.value[key] ?? '' + + const textChanged = + (before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '') + const labelChanged = labelBefore !== labelNow + + if (textChanged || labelChanged) { + await persistSection(key, labelChanged ? labelNow : null) + } + } + + await fetchSong() + finishSaving() emit('updated') } catch { stopSaving() + error.value = 'Speichern fehlgeschlagen.' } } -const buildPayload = () => ({ - title: title.value, - ccli_id: ccliId.value || null, - copyright_text: copyrightText.value || null, -}) +async function persistSection(sectionId, labelName) { + const body = { + slides: buildSectionSlides(sectionId), + } -// 500ms debounce for text inputs -const debouncedSave = useDebounceFn((data) => { - performSave(data) -}, 500) + if (labelName) { + body.label_name = labelName + } -const onTextInput = () => { - debouncedSave(buildPayload()) + const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': getCsrfToken(), + }, + credentials: 'same-origin', + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new Error('Sektion konnte nicht gespeichert werden.') + } } -// Immediate save on blur (cancel pending debounce) -const onFieldBlur = () => { - debouncedSave.cancel?.() - performSave(buildPayload()) +/* ── Close handling (guard unsaved changes) ── */ + +function requestClose() { + if (isDirty.value && !window.confirm('Es gibt ungespeicherte Änderungen. Wirklich schließen?')) { + return + } + + emit('close') } /* ── Arrangement props ── */ @@ -294,54 +404,36 @@ function buildSectionSlides(sectionId) { })) } -async function saveSection(sectionId) { - if (!props.songId || !sectionDrafts.value[sectionId]) return +/* ── Section headline combobox ── */ - startSaving() - - try { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' - const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': csrfToken, - }, - credentials: 'same-origin', - body: JSON.stringify({ - slides: buildSectionSlides(sectionId), - }), - }) - - if (!response.ok) { - throw new Error('Sektion konnte nicht gespeichert werden.') - } - - const json = await response.json() - songData.value = json.data - setSectionDrafts(json.data) - finishSaving() - emit('updated') - } catch { - stopSaving() - } +function headlineDraftFor(group) { + return sectionLabels.value[sectionKey(group)] ?? group.name ?? '' } -function onSectionInput(sectionId) { - if (!sectionSaveDebouncers.has(sectionId)) { - sectionSaveDebouncers.set(sectionId, useDebounceFn(() => { - saveSection(sectionId) - }, 600)) - } - - sectionSaveDebouncers.get(sectionId)() +function setHeadline(group, name) { + sectionLabels.value[sectionKey(group)] = name } -function onSectionBlur(sectionId) { - sectionSaveDebouncers.get(sectionId)?.cancel?.() - saveSection(sectionId) +function selectHeadline(group, name) { + setHeadline(group, name) + headlineDropdownFor.value = null +} + +function openHeadlineDropdown(group) { + headlineDropdownFor.value = sectionKey(group) +} + +function closeHeadlineDropdown() { + setTimeout(() => { + headlineDropdownFor.value = null + }, 150) +} + +function filteredHeadlineOptions(group) { + const term = (sectionLabels.value[sectionKey(group)] ?? '').trim().toLowerCase() + if (term === '') return sectionLabelOptions.value + + return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term)) } async function deleteSection(sectionId) { @@ -350,13 +442,12 @@ async function deleteSection(sectionId) { startSaving() try { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), { method: 'DELETE', headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': csrfToken, + 'X-CSRF-TOKEN': getCsrfToken(), }, credentials: 'same-origin', }) @@ -368,6 +459,7 @@ async function deleteSection(sectionId) { const json = await response.json() songData.value = json.data setSectionDrafts(json.data) + snapshotPristine() finishSaving() emit('updated') } catch { @@ -381,14 +473,13 @@ async function addSection() { startSaving() try { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '' const response = await fetch(route('songs.sections.store', props.songId), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': csrfToken, + 'X-CSRF-TOKEN': getCsrfToken(), }, credentials: 'same-origin', body: JSON.stringify({ @@ -417,13 +508,13 @@ async function addSection() { const closeOnEscape = (e) => { if (e.key === 'Escape' && props.show) { - emit('close') + requestClose() } } const closeOnBackdrop = (e) => { if (e.target === e.currentTarget) { - emit('close') + requestClose() } } @@ -543,7 +634,7 @@ onUnmounted(() => { data-testid="song-edit-modal-close-button" type="button" class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600" - @click="emit('close')" + @click="requestClose" > <svg class="h-5 w-5" @@ -648,8 +739,6 @@ onUnmounted(() => { type="text" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" placeholder="Songtitel eingeben…" - @input="onTextInput" - @blur="onFieldBlur" > </div> @@ -667,8 +756,6 @@ onUnmounted(() => { type="text" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" placeholder="z.B. 123456" - @input="onTextInput" - @blur="onFieldBlur" > </div> @@ -715,8 +802,6 @@ onUnmounted(() => { rows="3" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" placeholder="Copyright-Informationen…" - @input="onTextInput" - @blur="onFieldBlur" /> </div> </div> @@ -731,7 +816,7 @@ onUnmounted(() => { </h3> <p class="mt-1 text-sm text-gray-500"> - Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch. + Leerzeilen trennen einzelne Folien. Klicke „Speichern", um Deine Änderungen zu sichern. </p> </div> @@ -835,17 +920,60 @@ onUnmounted(() => { v-for="group in songData.groups" :key="group.section_id ?? group.id" data-testid="section-block" - class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm" + :class="[ + 'rounded-lg border p-4 shadow-sm', + group.locked ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white', + ]" > <div class="mb-3 flex items-center justify-between gap-3"> - <span - class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm" - :style="{ backgroundColor: group.color ?? '#6b7280' }" - > - {{ group.name }} - </span> + <div class="relative flex items-center gap-2"> + <span + class="inline-block h-4 w-4 shrink-0 rounded-full shadow-sm" + :style="{ backgroundColor: group.color ?? '#6b7280' }" + /> + <span + v-if="group.locked" + data-testid="section-headline-locked" + class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-sm font-semibold text-gray-500" + > + <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> + </svg> + {{ group.name }} + </span> + <template v-else> + <input + data-testid="section-headline-input" + :value="headlineDraftFor(group)" + type="text" + autocomplete="off" + class="w-48 rounded-md border-gray-300 text-sm font-semibold shadow-sm focus:border-amber-500 focus:ring-amber-500" + placeholder="Abschnitt wählen…" + @input="setHeadline(group, $event.target.value)" + @focus="openHeadlineDropdown(group)" + @blur="closeHeadlineDropdown" + > + <div + v-if="headlineDropdownFor === (group.section_id ?? group.id) && filteredHeadlineOptions(group).length > 0" + data-testid="section-headline-dropdown" + class="absolute left-6 top-full z-30 mt-1 max-h-56 w-48 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg" + > + <button + v-for="labelName in filteredHeadlineOptions(group)" + :key="labelName" + type="button" + data-testid="section-headline-option" + class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50" + @mousedown.prevent="selectHeadline(group, labelName)" + > + {{ labelName }} + </button> + </div> + </template> + </div> <button + v-if="!group.locked" data-testid="section-delete-button" type="button" class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700" @@ -858,7 +986,20 @@ onUnmounted(() => { </button> </div> - <div :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''"> + <div + v-if="group.locked" + class="flex items-center gap-1.5 text-xs text-gray-400" + > + <svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> + </svg> + Gesperrt – wird automatisch verwaltet + </div> + + <div + v-else + :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''" + > <div> <label class="mb-1 block text-sm font-medium text-gray-700"> Originaltext @@ -869,8 +1010,6 @@ onUnmounted(() => { rows="6" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" placeholder="Folientext…" - @input="onSectionInput(group.section_id ?? group.id)" - @blur="onSectionBlur(group.section_id ?? group.id)" /> </div> @@ -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)" /> </div> </div> @@ -907,6 +1044,31 @@ onUnmounted(() => { /> </div> </div> + + <!-- Footer: Save / Close --> + <div + v-if="songData" + class="flex items-center justify-end gap-3 rounded-b-xl border-t border-gray-200 bg-gray-50 px-6 py-4" + > + <button + data-testid="song-edit-close" + type="button" + class="rounded-md px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200" + @click="requestClose" + > + Schließen + </button> + + <button + data-testid="song-edit-save" + type="button" + class="rounded-md bg-amber-500 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50" + :disabled="!isDirty || saving" + @click="saveAll" + > + Speichern + </button> + </div> </div> </div> </Transition> 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') + } +} </script> <template> @@ -587,7 +619,11 @@ async function downloadService() { :service-song-name="(arrangementDialogItem.service_song?.cts_song_name ?? arrangementDialogItem.serviceSong?.cts_song_name) ?? arrangementDialogItem.title ?? ''" :service-song-ccli-id="String((arrangementDialogItem.service_song?.cts_ccli_id ?? arrangementDialogItem.serviceSong?.cts_ccli_id) ?? '')" :songs-catalog="songsCatalog" + :worship-leader-name="service.worship_leader_name ?? null" + :service-song-arrangement-id="arrangementDialogItem.service_song?.song_arrangement_id ?? arrangementDialogItem.serviceSong?.song_arrangement_id ?? null" + :default-arrangement-id="(getArrangements(arrangementDialogItem).find((a) => a.is_default))?.id ?? null" @close="onArrangementDialogClosed" + @unassigned="onArrangementDialogClosed" /> <!-- Sticky bottom action bar --> @@ -637,6 +673,17 @@ async function downloadService() { </button> </template> <template v-else> + <button + data-testid="download-preview-button" + type="button" + class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700" + @click="downloadPreview" + > + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> + </svg> + Vorschau herunterladen + </button> <button data-testid="service-edit-finalize-button" type="button" diff --git a/resources/js/Pages/Services/Index.vue b/resources/js/Pages/Services/Index.vue index 6ad6b65..ae98910 100644 --- a/resources/js/Pages/Services/Index.vue +++ b/resources/js/Pages/Services/Index.vue @@ -1,6 +1,6 @@ <script setup> import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' -import { Head, router } from '@inertiajs/vue3' +import { Head, Link, router } from '@inertiajs/vue3' import { ref, computed } from 'vue' const props = defineProps({ @@ -187,6 +187,38 @@ async function downloadService(serviceId) { } } +async function downloadPreview(serviceId) { + try { + const response = await fetch(route('services.download-preview', serviceId), { + 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') + } +} + function mappingStatusClass(service) { if (service.songs_total_count === 0) { return 'text-red-600' @@ -298,7 +330,7 @@ function stateIconClass(isDone) { <tbody class="divide-y divide-gray-100 bg-white"> <tr v-for="service in services" :key="service.id" :data-testid="`service-list-row-${service.id}`" class="align-top hover:bg-gray-50/60"> <td class="px-4 py-4"> - <div class="font-medium text-gray-900" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</div> + <Link :href="route('services.edit', service.id)" class="font-medium text-gray-900 hover:underline cursor-pointer" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</Link> <div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div> </td> @@ -422,6 +454,14 @@ function stateIconClass(isDone) { > Abschließen </button> + <button + data-testid="download-preview-button" + type="button" + class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700" + @click="downloadPreview(service.id)" + > + Vorschau herunterladen + </button> <button data-testid="service-list-delete-button" type="button" diff --git a/resources/js/Pages/Settings.vue b/resources/js/Pages/Settings.vue index 72a5dc0..32b1fce 100644 --- a/resources/js/Pages/Settings.vue +++ b/resources/js/Pages/Settings.vue @@ -1,11 +1,14 @@ <script setup> import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' +import MacroPicker from '@/Components/MacroPicker.vue' +import LabelPicker from '@/Components/LabelPicker.vue' import AgendaSettings from './Settings/AgendaSettings.vue' +import ExportProFiles from './Settings/ExportProFiles.vue' import LabelImport from './Settings/LabelImport.vue' import MacroAssignments from './Settings/MacroAssignments.vue' import MacroImport from './Settings/MacroImport.vue' import { Head, router } from '@inertiajs/vue3' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' const props = defineProps({ settings: { type: Object, default: () => ({}) }, @@ -15,15 +18,16 @@ const props = defineProps({ collections: { type: Array, default: () => [] }, last_macros_import: { type: Object, default: () => ({}) }, last_labels_import: { type: Object, default: () => ({}) }, + export_pro_files: { type: Object, default: () => ({ prefix: [], postfix: [] }) }, }) const submenus = [ { key: 'assignments', label: 'Makro-Zuweisungen' }, - { key: 'macros', label: 'Makro-Import' }, - { key: 'labels', label: 'Label-Import' }, + { key: 'macros', label: 'Makro' }, + { key: 'labels', label: 'Label' }, { key: 'agenda', label: 'Agenda' }, { key: 'ccli', label: 'CCLI Import' }, - { key: 'namenseinblender', label: 'Namenseinblender' }, + { key: 'export-files', label: 'Export-Dateien' }, ] const activeSubmenu = ref('assignments') @@ -68,6 +72,22 @@ async function updateSetting(key, value) { // silent fail } } + +// Task 5: Namenseinblender macro picker (moved into assignments panel) +const nametagMacroId = ref(props.settings.namenseinblender_macro_id ? Number(props.settings.namenseinblender_macro_id) : null) +watch(nametagMacroId, (value) => { + updateSetting('namenseinblender_macro_id', value === null ? '' : String(value)) +}) + +// Task 6: Song prefix/postfix label defaults +const songPrefixLabelId = ref(props.settings.song_prefix_label_id ? Number(props.settings.song_prefix_label_id) : null) +const songPostfixLabelId = ref(props.settings.song_postfix_label_id ? Number(props.settings.song_postfix_label_id) : null) +watch(songPrefixLabelId, (value) => { + updateSetting('song_prefix_label_id', value === null ? '' : String(value)) +}) +watch(songPostfixLabelId, (value) => { + updateSetting('song_postfix_label_id', value === null ? '' : String(value)) +}) </script> <template> @@ -125,15 +145,31 @@ async function updateSetting(key, value) { </div> <!-- Content panel --> - <div class="flex-1 p-6" data-testid="settings-active-panel"> - <MacroAssignments - v-if="activeSubmenu === 'assignments'" - :assignments="assignments" - :macros="macros" - :labels="labels" - :collections="collections" - @switch-submenu="switchSubmenu" - /> + <div class="flex-1 overflow-y-auto p-6 max-h-[calc(100vh-12rem)]" data-testid="settings-active-panel"> + <!-- Makro-Zuweisungen: includes Namenseinblender at top --> + <div v-if="activeSubmenu === 'assignments'" class="space-y-6"> + <!-- Task 5: Namenseinblender MacroPicker at top of assignments --> + <div class="rounded-lg border border-gray-200 p-4"> + <h3 class="mb-1 text-sm font-semibold text-gray-900">Namenseinblender</h3> + <p class="mb-3 text-xs text-gray-500"> + Wähle das ProPresenter-Makro für die Namenseinblendung bei Moderation und Predigt. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert. + </p> + <MacroPicker + v-model="nametagMacroId" + :macros="macros" + :collections="collections" + data-testid="namenseinblender-macro-picker" + /> + </div> + + <MacroAssignments + :assignments="assignments" + :macros="macros" + :labels="labels" + :collections="collections" + @switch-submenu="switchSubmenu" + /> + </div> <MacroImport v-if="activeSubmenu === 'macros'" @@ -143,11 +179,39 @@ async function updateSetting(key, value) { @switch-submenu="switchSubmenu" /> - <LabelImport - v-if="activeSubmenu === 'labels'" - :labels="labels" - :last_labels_import="last_labels_import" - /> + <!-- Label panel: import + prefix/postfix defaults --> + <div v-if="activeSubmenu === 'labels'" class="space-y-6"> + <LabelImport + :labels="labels" + :last_labels_import="last_labels_import" + /> + + <!-- Task 6: Song prefix/postfix label defaults --> + <div class="rounded-lg border border-gray-200 p-4 space-y-4"> + <div> + <h3 class="text-sm font-semibold text-gray-900">Standard-Labels für Songs</h3> + <p class="mt-1 text-xs text-gray-500"> + Diese Labels werden bei jedem Song-Export automatisch als Prefix (z. B. COPYRIGHT) und Postfix (z. B. BLANK) angehängt. + </p> + </div> + <div> + <label class="block text-xs font-medium text-gray-700 mb-1">Prefix-Label (Standard: COPYRIGHT)</label> + <LabelPicker + v-model="songPrefixLabelId" + :labels="labels" + data-testid="song-prefix-label-picker" + /> + </div> + <div> + <label class="block text-xs font-medium text-gray-700 mb-1">Postfix-Label (Standard: BLANK)</label> + <LabelPicker + v-model="songPostfixLabelId" + :labels="labels" + data-testid="song-postfix-label-picker" + /> + </div> + </div> + </div> <AgendaSettings v-if="activeSubmenu === 'agenda'" @@ -224,59 +288,12 @@ async function updateSetting(key, value) { </div> </div> - <!-- Namenseinblender Macro Settings --> - <div v-if="activeSubmenu === 'namenseinblender'" class="space-y-6"> - <div> - <h3 class="text-base font-semibold text-gray-900">Namenseinblender-Makro</h3> - <p class="mt-1 text-sm text-gray-500"> - Konfiguriere das ProPresenter-Makro, das für die Namenseinblendung bei Moderation und Predigt verwendet wird. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert. - </p> - </div> - <div class="rounded-lg border border-gray-200 p-4 space-y-4"> - <div> - <label class="block text-sm font-medium text-gray-700 mb-1">Makro-Name</label> - <input - type="text" - :value="settings.namenseinblender_macro_name || ''" - class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" - placeholder="z.B. Namenseinblender" - data-testid="namenseinblender-macro-name" - @change="updateSetting('namenseinblender_macro_name', $event.target.value)" - /> - </div> - <div> - <label class="block text-sm font-medium text-gray-700 mb-1">Makro-UUID</label> - <input - type="text" - :value="settings.namenseinblender_macro_uuid || ''" - class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono" - placeholder="UUID des Makros" - data-testid="namenseinblender-macro" - @change="updateSetting('namenseinblender_macro_uuid', $event.target.value)" - /> - </div> - <div> - <label class="block text-sm font-medium text-gray-700 mb-1">Collection-Name</label> - <input - type="text" - :value="settings.namenseinblender_macro_collection_name || '--MAIN--'" - class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" - placeholder="--MAIN--" - @change="updateSetting('namenseinblender_macro_collection_name', $event.target.value)" - /> - </div> - <div> - <label class="block text-sm font-medium text-gray-700 mb-1">Collection-UUID</label> - <input - type="text" - :value="settings.namenseinblender_macro_collection_uuid || ''" - class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono" - placeholder="UUID der Collection" - @change="updateSetting('namenseinblender_macro_collection_uuid', $event.target.value)" - /> - </div> - </div> - </div> + <!-- Task 7: Export-Dateien submenu --> + <ExportProFiles + v-if="activeSubmenu === 'export-files'" + :prefix-files="export_pro_files.prefix" + :postfix-files="export_pro_files.postfix" + /> </div> </div> </div> diff --git a/resources/js/Pages/Settings/ExportProFiles.vue b/resources/js/Pages/Settings/ExportProFiles.vue new file mode 100644 index 0000000..70ceb1e --- /dev/null +++ b/resources/js/Pages/Settings/ExportProFiles.vue @@ -0,0 +1,226 @@ +<script setup> +import { ref } from 'vue' +import { route } from 'ziggy-js' + +const props = defineProps({ + prefixFiles: { type: Array, default: () => [] }, + postfixFiles: { type: Array, default: () => [] }, +}) + +const prefixDragActive = ref(false) +const postfixDragActive = ref(false) +const uploading = ref(false) +const error = ref(null) + +function getXsrfToken() { + return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '') +} + +async function uploadFiles(type, files) { + if (!files || files.length === 0) return + uploading.value = true + error.value = null + + const form = new FormData() + form.append('type', type) + for (const file of files) { + form.append('files[]', file) + } + + try { + const res = await fetch(route('settings.export-pro-files.store'), { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': getXsrfToken(), + }, + body: form, + }) + if (!res.ok) { + const data = await res.json() + error.value = data.message || 'Upload fehlgeschlagen' + return + } + window.location.reload() + } catch { + error.value = 'Netzwerkfehler beim Upload' + } finally { + uploading.value = false + } +} + +function handlePrefixChange(event) { + const files = event.target.files + if (!files || files.length === 0) return + event.target.value = '' + uploadFiles('prefix', files) +} + +function handlePostfixChange(event) { + const files = event.target.files + if (!files || files.length === 0) return + event.target.value = '' + uploadFiles('postfix', files) +} + +function handlePrefixDrop(event) { + prefixDragActive.value = false + uploadFiles('prefix', event.dataTransfer.files) +} + +function handlePostfixDrop(event) { + postfixDragActive.value = false + uploadFiles('postfix', event.dataTransfer.files) +} + +async function deleteFile(id) { + try { + await fetch(route('settings.export-pro-files.destroy', id), { + method: 'DELETE', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': getXsrfToken(), + }, + }) + window.location.reload() + } catch { + // silent fail + } +} +</script> + +<template> + <div class="space-y-8"> + <div> + <h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3> + <p class="text-xs text-gray-500"> + Diese .pro-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt. + </p> + </div> + + <div + v-if="error" + class="rounded-lg bg-red-50 p-3 text-sm text-red-700" + > + {{ error }} + </div> + + <!-- Prefix section --> + <div> + <h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Prefix-Dateien</h4> + <p class="mb-3 text-xs text-gray-400">Werden vor dem Gottesdienst eingefügt.</p> + + <label + class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" + :class="prefixDragActive + ? 'border-amber-400 bg-amber-50' + : 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'" + data-testid="export-prefix-dropzone" + @dragover.prevent + @dragenter.prevent="prefixDragActive = true" + @dragleave.prevent="prefixDragActive = false" + @drop.prevent="handlePrefixDrop" + > + <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> + </svg> + <span class="text-sm text-gray-500"> + {{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }} + </span> + <span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> + <input + type="file" + class="hidden" + accept=".pro" + multiple + :disabled="uploading" + data-testid="export-prefix-file-input" + @change="handlePrefixChange" + /> + </label> + + <div v-if="prefixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100"> + <div + v-for="file in prefixFiles" + :key="file.id" + class="flex items-center gap-3 px-3 py-2 text-sm" + > + <svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> + </svg> + <span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span> + <span class="text-xs text-gray-400">#{{ file.order + 1 }}</span> + <button + class="ml-2 text-gray-400 hover:text-red-500" + :data-testid="'export-pro-file-delete-' + file.id" + @click="deleteFile(file.id)" + > + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + </div> + <p v-else class="text-sm text-gray-400">Noch keine Prefix-Dateien hochgeladen.</p> + </div> + + <!-- Postfix section --> + <div> + <h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Postfix-Dateien</h4> + <p class="mb-3 text-xs text-gray-400">Werden nach dem Gottesdienst angehängt.</p> + + <label + class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" + :class="postfixDragActive + ? 'border-amber-400 bg-amber-50' + : 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'" + data-testid="export-postfix-dropzone" + @dragover.prevent + @dragenter.prevent="postfixDragActive = true" + @dragleave.prevent="postfixDragActive = false" + @drop.prevent="handlePostfixDrop" + > + <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> + </svg> + <span class="text-sm text-gray-500"> + {{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }} + </span> + <span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> + <input + type="file" + class="hidden" + accept=".pro" + multiple + :disabled="uploading" + data-testid="export-postfix-file-input" + @change="handlePostfixChange" + /> + </label> + + <div v-if="postfixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100"> + <div + v-for="file in postfixFiles" + :key="file.id" + class="flex items-center gap-3 px-3 py-2 text-sm" + > + <svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> + </svg> + <span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span> + <span class="text-xs text-gray-400">#{{ file.order + 1 }}</span> + <button + class="ml-2 text-gray-400 hover:text-red-500" + :data-testid="'export-pro-file-delete-' + file.id" + @click="deleteFile(file.id)" + > + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + </div> + <p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p> + </div> + </div> +</template> diff --git a/resources/js/Pages/Settings/LabelImport.vue b/resources/js/Pages/Settings/LabelImport.vue index c1750d1..61aa78b 100644 --- a/resources/js/Pages/Settings/LabelImport.vue +++ b/resources/js/Pages/Settings/LabelImport.vue @@ -10,11 +10,11 @@ const props = defineProps({ const uploading = ref(false) const result = ref(null) const error = ref(null) +const dragActive = ref(false) const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name))) -async function handleFileChange(event) { - const file = event.target.files[0] +async function uploadFile(file) { if (!file) return uploading.value = true error.value = null @@ -38,7 +38,6 @@ async function handleFileChange(event) { return } result.value = await res.json() - event.target.value = '' window.location.reload() } catch { error.value = 'Netzwerkfehler beim Upload' @@ -46,11 +45,24 @@ async function handleFileChange(event) { uploading.value = false } } + +function handleFileChange(event) { + const file = event.target.files[0] + if (!file) return + event.target.value = '' + uploadFile(file) +} + +function handleDrop(event) { + dragActive.value = false + const file = event.dataTransfer.files[0] + uploadFile(file) +} </script> <template> <div> - <h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3> + <h3 class="mb-1 text-sm font-semibold text-gray-900">Label</h3> <p class="mb-1 text-xs text-gray-500"> Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>. @@ -71,14 +83,26 @@ async function handleFileChange(event) { </span> </p> + <!-- Task 4: Hint about importing both Labels and Groups files --> + <div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800"> + <strong>Hinweis:</strong> Importiere sowohl die Datei „Labels" als auch die Datei „Groups", damit sowohl die automatischen als auch die manuellen Labels verfügbar sind. + </div> + <div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400"> Letzter Import: {{ last_labels_import.at }} <span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span> </div> <label - class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50" + class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" + :class="dragActive + ? 'border-amber-400 bg-amber-50' + : 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'" data-testid="labels-upload-area" + @dragover.prevent + @dragenter.prevent="dragActive = true" + @dragleave.prevent="dragActive = false" + @drop.prevent="handleDrop" > <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> diff --git a/resources/js/Pages/Settings/MacroImport.vue b/resources/js/Pages/Settings/MacroImport.vue index 65ff5e8..f9adae9 100644 --- a/resources/js/Pages/Settings/MacroImport.vue +++ b/resources/js/Pages/Settings/MacroImport.vue @@ -14,6 +14,7 @@ const uploading = ref(false) const result = ref(null) const error = ref(null) const selectedCollection = ref(null) +const dragActive = ref(false) const filteredMacros = computed(() => { if (!selectedCollection.value) return props.macros @@ -23,8 +24,7 @@ const filteredMacros = computed(() => { return props.macros.filter((m) => ids.includes(m.id)) }) -async function handleFileChange(event) { - const file = event.target.files[0] +async function uploadFile(file) { if (!file) return uploading.value = true error.value = null @@ -48,7 +48,6 @@ async function handleFileChange(event) { return } result.value = await res.json() - event.target.value = '' window.location.reload() } catch { error.value = 'Netzwerkfehler beim Upload' @@ -56,11 +55,24 @@ async function handleFileChange(event) { uploading.value = false } } + +function handleFileChange(event) { + const file = event.target.files[0] + if (!file) return + event.target.value = '' + uploadFile(file) +} + +function handleDrop(event) { + dragActive.value = false + const file = event.dataTransfer.files[0] + uploadFile(file) +} </script> <template> <div> - <h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3> + <h3 class="mb-1 text-sm font-semibold text-gray-900">Makro</h3> <p class="mb-1 text-xs text-gray-500"> Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>. @@ -87,8 +99,15 @@ async function handleFileChange(event) { </div> <label - class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50" + class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors" + :class="dragActive + ? 'border-amber-400 bg-amber-50' + : 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'" data-testid="macros-upload-area" + @dragover.prevent + @dragenter.prevent="dragActive = true" + @dragleave.prevent="dragActive = false" + @drop.prevent="handleDrop" > <svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> diff --git a/resources/js/Pages/Songs/ImportFromCcliPaste.vue b/resources/js/Pages/Songs/ImportFromCcliPaste.vue index 4c9bc16..ad498f1 100644 --- a/resources/js/Pages/Songs/ImportFromCcliPaste.vue +++ b/resources/js/Pages/Songs/ImportFromCcliPaste.vue @@ -14,6 +14,9 @@ const props = defineProps({ const editSongId = ref(null) function handleClose() { + // Don't navigate away if an edit is opening (import & edit flow). + if (editSongId.value !== null) return + router.visit(route('songs.index')) } @@ -22,6 +25,14 @@ function handleImported(songId, mode) { router.visit(route('songs.index')) } } + +function handleEditSong(id) { + editSongId.value = id +} + +function handleEditClose() { + router.visit(route('songs.index')) +} </script> <template> @@ -48,18 +59,19 @@ function handleImported(songId, mode) { <!-- Always-open paste dialog on this page --> <CcliPasteDialog - :open="true" + :open="editSongId === null" mode="songdb" :prefilled-text="prefilledText" + :auto-edit="prefilledText !== null" @close="handleClose" @imported="handleImported" - @edit-song="(id) => { editSongId = id }" + @edit-song="handleEditSong" /> <SongEditModal :show="editSongId !== null" :song-id="editSongId" - @close="() => router.visit(route('songs.index'))" + @close="handleEditClose" /> </div> </div> diff --git a/routes/web.php b/routes/web.php index 46d2697..0065a28 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\BookmarkletController; use App\Http\Controllers\CcliPasteController; +use App\Http\Controllers\ExportProFileController; use App\Http\Controllers\LabelImportController; use App\Http\Controllers\MacroAssignmentController; use App\Http\Controllers\MacroImportController; @@ -15,6 +16,7 @@ use App\Http\Controllers\SongSectionController; use App\Http\Controllers\SyncController; use App\Http\Controllers\TranslationController; +use App\Models\Service; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -54,7 +56,13 @@ Route::middleware('auth')->group(function () { Route::get('/', function () { - return redirect()->route('dashboard'); + $service = Service::nextUpcoming()->first(); + + if ($service) { + return redirect()->route('services.edit', $service->id); + } + + return redirect()->route('services.index'); }); Route::get('/dashboard', function () { @@ -66,6 +74,7 @@ Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen'); Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy'); Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download'); + Route::get('/services/{service}/download-preview', [ServiceController::class, 'downloadPreview'])->name('services.download-preview'); Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle'); Route::get('/services/{service}/agenda-items/{agendaItem}/download', [ServiceController::class, 'downloadAgendaItem'])->name('services.agenda-item.download'); Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit'); @@ -115,6 +124,8 @@ | Makro- und Label-Import (ProPresenter) |-------------------------------------------------------------------------- */ + Route::post('/settings/export-pro-files', [ExportProFileController::class, 'store'])->name('settings.export-pro-files.store'); + Route::delete('/settings/export-pro-files/{exportProFile}', [ExportProFileController::class, 'destroy'])->name('settings.export-pro-files.destroy'); Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import'); Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import'); diff --git a/tests/Feature/CcliImportServiceTest.php b/tests/Feature/CcliImportServiceTest.php index c7150eb..7f74414 100644 --- a/tests/Feature/CcliImportServiceTest.php +++ b/tests/Feature/CcliImportServiceTest.php @@ -44,7 +44,7 @@ function ccliFixture(string $name): string expect($arrangement)->not->toBeNull() ->and($arrangement->is_default)->toBeTrue() - ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5) + ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7) ->and(SongSlide::count())->toBe(5); }); @@ -93,7 +93,7 @@ function ccliFixture(string $name): string ->and($song->title)->toBe('Heilig ist der Herr') ->and($song->author)->toBe('Albert Frey') ->and($arrangement)->not->toBeNull() - ->and($arrangement->arrangementSections)->toHaveCount(2) + ->and($arrangement->arrangementSections)->toHaveCount(4) ->and(SongSlide::count())->toBe(7); }); diff --git a/tests/Feature/HomeTest.php b/tests/Feature/HomeTest.php index ee2422d..b734743 100644 --- a/tests/Feature/HomeTest.php +++ b/tests/Feature/HomeTest.php @@ -1,6 +1,8 @@ <?php +use App\Models\Service; use App\Models\User; +use Carbon\Carbon; test('home route redirects unauthenticated users to login', function () { $response = $this->get('/'); @@ -8,10 +10,23 @@ $response->assertRedirect('/login'); }); -test('home route redirects authenticated users to dashboard', function () { +test('home route redirects authenticated users to next upcoming service edit page', function () { + Carbon::setTestNow('2026-06-01 10:00:00'); + + $user = User::factory()->create(); + $service = Service::factory()->create(['date' => now()->addWeek()]); + + $response = $this->actingAs($user)->get('/'); + + $response->assertRedirect(route('services.edit', $service->id)); +}); + +test('home route redirects authenticated users to services index when no upcoming service exists', function () { + Carbon::setTestNow('2026-06-01 10:00:00'); + $user = User::factory()->create(); $response = $this->actingAs($user)->get('/'); - $response->assertRedirect(route('dashboard')); + $response->assertRedirect(route('services.index')); }); diff --git a/tests/Feature/KeyVisualFallbackTest.php b/tests/Feature/KeyVisualFallbackTest.php index d6c08f1..8ff3774 100644 --- a/tests/Feature/KeyVisualFallbackTest.php +++ b/tests/Feature/KeyVisualFallbackTest.php @@ -25,7 +25,7 @@ protected function setUp(): void Storage::fake('public'); } - public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void + public function test_non_song_agenda_item_without_slides_becomes_headline(): void { Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); @@ -48,18 +48,24 @@ public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisua $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $embeddedProFiles = $playlist->getEmbeddedProFiles(); - $fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro'); + // Content-less item before any real content → HEADLINE (not keyvisual fallback) $this->assertCount(1, $embeddedProFiles); - $this->assertNotNull($fallbackSong); $this->assertSame($slideCountBefore, Slide::count()); - $slides = $this->allParserSlides($fallbackSong); - $this->assertCount(1, $slides); - $this->assertTrue($slides[0]->hasBackgroundMedia()); - $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl()); - $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); - $this->assertSame('keyvisual-image', $playlist->getEmbeddedMediaFiles()['KEY_VISUAL.jpg']); + // Headline filename matches pattern: Begrüßung-headline-<uniqid>.pro + $headlineKey = null; + foreach (array_keys($embeddedProFiles) as $key) { + if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) { + $headlineKey = $key; + break; + } + } + $this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung'); + $this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey); + + // No KEY_VISUAL.jpg embedded (headline has no background media) + $this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->cleanupTempDir($result['temp_dir']); } @@ -149,7 +155,7 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_ $this->cleanupTempDir($result['temp_dir']); } - public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void + public function test_empty_non_song_item_before_content_becomes_headline(): void { $service = Service::factory()->create([ 'title' => 'Ohne Keyvisual', @@ -181,10 +187,25 @@ public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entr $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); + $embeddedProFiles = $playlist->getEmbeddedProFiles(); - $this->assertCount(1, $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); $this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro')); - $this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro')); + + // Headline filename matches pattern: Begrüßung ohne Folien-headline-<uniqid>.pro + $headlineKey = null; + foreach (array_keys($embeddedProFiles) as $key) { + if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) { + $headlineKey = $key; + break; + } + } + $this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien'); + + // No KEY_VISUAL.jpg embedded (no keyvisual configured) + $this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->cleanupTempDir($result['temp_dir']); } diff --git a/tests/Feature/NameTagResolverTest.php b/tests/Feature/NameTagResolverTest.php index 832e2a7..2af6632 100644 --- a/tests/Feature/NameTagResolverTest.php +++ b/tests/Feature/NameTagResolverTest.php @@ -138,3 +138,42 @@ expect($name)->toBeNull(); }); + +test('worship leader resolves from lobpreis agenda item responsibles', function () { + $service = Service::factory()->create(); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'responsible' => [['name' => 'Moderator Max']], + 'sort_order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreis', + 'is_before_event' => false, + 'responsible' => [['name' => 'Lea Leiter']], + 'sort_order' => 2, + ]); + + $name = app(NameTagResolver::class)->worshipLeaderFor($service); + + expect($name)->toBe('Lea Leiter'); +}); + +test('worship leader returns null without a lobpreis agenda item', function () { + $service = Service::factory()->create(); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'responsible' => [['name' => 'Moderator Max']], + 'sort_order' => 1, + ]); + + $name = app(NameTagResolver::class)->worshipLeaderFor($service); + + expect($name)->toBeNull(); +}); diff --git a/tests/Feature/PlaylistExportTest.php b/tests/Feature/PlaylistExportTest.php index fce9ad4..b812ae5 100644 --- a/tests/Feature/PlaylistExportTest.php +++ b/tests/Feature/PlaylistExportTest.php @@ -78,8 +78,7 @@ private function createSlide(array $attributes): Slide private function createTestableExportService(): PlaylistExportService { - return new class extends PlaylistExportService - { + return new class () extends PlaylistExportService { protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void { file_put_contents($path, 'mock-pro-file:'.$name); @@ -375,7 +374,7 @@ public function test_agenda_export_informationen_am_anfang_als_fallback(): void $this->cleanupTempDir($result['temp_dir']); } - public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs(): void + public function test_agenda_export_content_less_items_werden_als_headline_oder_uebersprungen(): void { $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skip Service']); @@ -419,8 +418,11 @@ public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs(): $this->assertEquals(0, $result['skipped']); $playlistContent = file_get_contents($result['path']); + // Song always appears $this->assertStringContainsString('Einziger Song', $playlistContent); - $this->assertStringNotContainsString('Begrüßung', $playlistContent); + // Begrüßung (before real content) → HEADLINE → appears in playlist + $this->assertStringContainsString('Begrüßung', $playlistContent); + // Gebet (first content-less item after real content, no keyvisual) → omitted $this->assertStringNotContainsString('Gebet', $playlistContent); $this->cleanupTempDir($result['temp_dir']); diff --git a/tests/Feature/ProFileImportTest.php b/tests/Feature/ProFileImportTest.php index b13ebe7..ebb82b6 100644 --- a/tests/Feature/ProFileImportTest.php +++ b/tests/Feature/ProFileImportTest.php @@ -34,7 +34,7 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo $song = Song::where('title', 'Test')->first(); $this->assertNotNull($song); - $this->assertSame(4, \App\Models\Label::count()); + $this->assertSame(6, \App\Models\Label::count()); $this->assertSame(5, \App\Models\SongSlide::count()); $this->assertSame(2, $song->arrangements()->count()); @@ -127,6 +127,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void $this->assertNotNull($normalArrangement); $this->assertTrue($normalArrangement->is_default); - $this->assertSame(5, $normalArrangement->arrangementLabels()->count()); + $this->assertSame(7, $normalArrangement->arrangementLabels()->count()); } } diff --git a/tests/Feature/SongCcliMetadataTest.php b/tests/Feature/SongCcliMetadataTest.php index 2f7864b..d1cdaac 100644 --- a/tests/Feature/SongCcliMetadataTest.php +++ b/tests/Feature/SongCcliMetadataTest.php @@ -45,5 +45,8 @@ $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Destruktive Migration'); - Artisan::call('migrate:rollback', ['--step' => 1]); + // The guarded migration (create_song_sections_and_rescope_slides) is 3rd from the top + // of the migration stack (2 newer migrations were added after it). + // Rolling back 3 steps reaches the destructive guard and triggers the RuntimeException. + Artisan::call('migrate:rollback', ['--step' => 3]); });