diff --git a/app/Http/Controllers/ExportProFileController.php b/app/Http/Controllers/ExportProFileController.php index 5cca89f..314b04e 100644 --- a/app/Http/Controllers/ExportProFileController.php +++ b/app/Http/Controllers/ExportProFileController.php @@ -13,7 +13,8 @@ class ExportProFileController extends Controller public function store(Request $request): JsonResponse { $request->validate([ - 'type' => ['required', Rule::in(['prefix', 'postfix'])], + 'type' => ['required', Rule::in(['prefix', 'postfix', 'keyword'])], + 'keyword' => [Rule::requiredIf(fn () => $request->input('type') === 'keyword'), 'nullable', 'string', 'max:255'], 'files' => ['required', 'array'], 'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'], ], [ @@ -21,6 +22,7 @@ public function store(Request $request): JsonResponse ]); $type = $request->input('type'); + $keyword = $type === 'keyword' ? $request->input('keyword') : null; $created = []; foreach ($request->file('files') as $file) { @@ -29,6 +31,7 @@ public function store(Request $request): JsonResponse $record = ExportProFile::create([ 'type' => $type, + 'keyword' => $keyword, 'original_name' => $file->getClientOriginalName(), 'stored_path' => $storedPath, 'order' => $maxOrder + 1, @@ -47,6 +50,25 @@ public function store(Request $request): JsonResponse ], 201); } + public function updateKeyword(Request $request, ExportProFile $exportProFile): JsonResponse + { + if ($exportProFile->type !== 'keyword') { + return response()->json([ + 'message' => 'Schlüsselwort kann nur für Schlüsselwort-Dateien geändert werden.', + ], 422); + } + + $validated = $request->validate([ + 'keyword' => ['required', 'string', 'max:255'], + ]); + + $exportProFile->update(['keyword' => $validated['keyword']]); + + return response()->json([ + 'message' => 'Schlüsselwort erfolgreich aktualisiert.', + ]); + } + public function destroy(ExportProFile $exportProFile): JsonResponse { Storage::disk('local')->delete($exportProFile->stored_path); diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 6ff7ff2..c395d6a 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -68,6 +68,7 @@ public function index(): Response 'export_pro_files' => [ 'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']), 'postfix' => ExportProFile::postfix()->orderBy('order')->get(['id', 'original_name', 'order']), + 'keyword' => ExportProFile::keyword()->orderBy('order')->get(['id', 'original_name', 'order', 'keyword']), ], ]); } diff --git a/app/Models/ExportProFile.php b/app/Models/ExportProFile.php index 250a000..b6735db 100644 --- a/app/Models/ExportProFile.php +++ b/app/Models/ExportProFile.php @@ -9,6 +9,7 @@ class ExportProFile extends Model { protected $fillable = [ 'type', + 'keyword', 'original_name', 'stored_path', 'order', @@ -30,4 +31,9 @@ public function scopePostfix(Builder $query): Builder { return $query->where('type', 'postfix'); } + + public function scopeKeyword(Builder $query): Builder + { + return $query->where('type', 'keyword'); + } } diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 7047e84..651cd1e 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -67,6 +67,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda $realContentEmitted = false; $keyvisualFallbackEmitted = false; + $keywordFiles = ExportProFile::keyword()->orderBy('order')->get(); + foreach ($agendaItems as $item) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { $moderatorName = trim((string) ($moderatorSlideData['text'] ?? '')); @@ -107,38 +109,51 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if ($this->countSongContentSlides($song) === 0) { $skippedUnmatched++; $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; + } else { + $proPath = $exportService->generateProFile($song, $service); + $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; + $destPath = $tempDir.'/'.$proFilename; + rename($proPath, $destPath); - continue; + $embeddedFiles[$proFilename] = file_get_contents($destPath); + $this->embedBackground($service, $embeddedFiles); + + $playlistItems[] = [ + 'type' => 'presentation', + 'name' => $song->title, + 'path' => $proFilename, + ]; + + $realContentEmitted = true; } - - $proPath = $exportService->generateProFile($song, $service); - $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; - $destPath = $tempDir.'/'.$proFilename; - rename($proPath, $destPath); - - $embeddedFiles[$proFilename] = file_get_contents($destPath); - $this->embedBackground($service, $embeddedFiles); - - $playlistItems[] = [ - 'type' => 'presentation', - 'name' => $song->title, - 'path' => $proFilename, - ]; - - $realContentEmitted = true; } else { $skippedUnmatched++; } + } else { + $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon'; - continue; - } + if ($isSermon) { + $this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles); - $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon'; + if ($item->slides->isNotEmpty()) { + $countBefore = count($playlistItems); + $label = $item->title ?: 'Folien'; + $this->addSlidesFromCollection( + $item->slides, + 'agenda_'.$item->id, + $label, + $tempDir, + $playlistItems, + $embeddedFiles, + $service, + $this->backgroundPartTypeForAgendaItem($item), + ); - if ($isSermon) { - $this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles); - - if ($item->slides->isNotEmpty()) { + if (count($playlistItems) > $countBefore) { + $realContentEmitted = true; + } + } + } elseif ($item->slides->isNotEmpty()) { $countBefore = count($playlistItems); $label = $item->title ?: 'Folien'; $this->addSlidesFromCollection( @@ -155,39 +170,36 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if (count($playlistItems) > $countBefore) { $realContentEmitted = true; } + } elseif (! $this->isNameTagAgendaItem($item)) { + if ($realContentEmitted && ! $keyvisualFallbackEmitted) { + $this->addKeyVisualFallbackPresentation( + $item, + $service, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + $keyvisualFallbackEmitted = true; + } else { + $this->addHeadlineItem( + $item, + $playlistItems, + ); + } } - } elseif ($item->slides->isNotEmpty()) { - $countBefore = count($playlistItems); - $label = $item->title ?: 'Folien'; - $this->addSlidesFromCollection( - $item->slides, - 'agenda_'.$item->id, - $label, - $tempDir, - $playlistItems, - $embeddedFiles, - $service, - $this->backgroundPartTypeForAgendaItem($item), - ); + } - if (count($playlistItems) > $countBefore) { - $realContentEmitted = true; + foreach ($keywordFiles as $kf) { + $keyword = trim((string) $kf->keyword); + if ($keyword === '') { + continue; } - } elseif (! $this->isNameTagAgendaItem($item)) { - if ($realContentEmitted && ! $keyvisualFallbackEmitted) { - $this->addKeyVisualFallbackPresentation( - $item, - $service, - $tempDir, - $playlistItems, - $embeddedFiles, - ); - $keyvisualFallbackEmitted = true; - } else { - $this->addHeadlineItem( - $item, - $playlistItems, - ); + + if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) { + $keywordItem = $this->embedExportProFile($kf, $embeddedFiles); + if ($keywordItem !== null) { + $playlistItems[] = $keywordItem; + } } } } @@ -551,80 +563,90 @@ private function addHeadlineItem( private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void { - $prefixEmbedded = []; $prefixItems = $this->buildExportProItems( ExportProFile::prefix()->orderBy('order')->get(), - 'PREFIX', - $prefixEmbedded, + $embeddedFiles, ); - $postfixEmbedded = []; $postfixItems = $this->buildExportProItems( ExportProFile::postfix()->orderBy('order')->get(), - 'POSTFIX', - $postfixEmbedded, + $embeddedFiles, ); $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); - $embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded); } /** @param \Illuminate\Support\Collection $files */ - private function buildExportProItems(\Illuminate\Support\Collection $files, string $marker, array &$embedded): array + private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array { $items = []; foreach ($files as $file) { - if (! Storage::disk('local')->exists($file->stored_path)) { - continue; - } - - $ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION)); - $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); - $embeddedProName = $marker.'_'.$file->order.'_'.$safeBase.'.pro'; - $displayName = pathinfo($file->original_name, PATHINFO_FILENAME); - - if ($ext === 'probundle') { - $storedAbsPath = Storage::disk('local')->path($file->stored_path); - - $extracted = $this->extractProBundle($storedAbsPath); - if ($extracted === null) { - continue; - } - - [$proBytes, $mediaFiles] = $extracted; - - foreach ($mediaFiles as $entryName => $entryBytes) { - $mediaName = basename((string) $entryName); - if (! isset($embedded[$mediaName])) { - $embedded[$mediaName] = $entryBytes; - } - } - - $embedded[$embeddedProName] = $proBytes; - $items[] = [ - 'type' => 'presentation', - 'name' => $displayName, - 'path' => $embeddedProName, - ]; - } else { - $bytes = Storage::disk('local')->get($file->stored_path); - if ($bytes === null) { - continue; - } - - $embedded[$embeddedProName] = $bytes; - $items[] = [ - 'type' => 'presentation', - 'name' => $displayName, - 'path' => $embeddedProName, - ]; + $item = $this->embedExportProFile($file, $embedded); + if ($item !== null) { + $items[] = $item; } } return $items; } + /** + * Read a single export .pro/.probundle file from the 'local' disk, embed its + * bytes (and any .probundle media) into $embeddedFiles, and return the matching + * playlist presentation item. Returns null when the file is missing or a + * .probundle has no inner .pro entry. + * + * The embedded .pro name uses the uppercased type as marker + * (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical. + * + * @return array{type: string, name: string, path: string}|null + */ + private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ?array + { + if (! Storage::disk('local')->exists($file->stored_path)) { + return null; + } + + $ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION)); + $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); + $embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro'; + $displayName = pathinfo($file->original_name, PATHINFO_FILENAME); + + if ($ext === 'probundle') { + $storedAbsPath = Storage::disk('local')->path($file->stored_path); + + $extracted = $this->extractProBundle($storedAbsPath); + if ($extracted === null) { + return null; + } + + [$proBytes, $mediaFiles] = $extracted; + + foreach ($mediaFiles as $entryName => $entryBytes) { + $mediaName = basename((string) $entryName); + if (! isset($embeddedFiles[$mediaName])) { + $embeddedFiles[$mediaName] = $entryBytes; + } + } + + $embeddedFiles[$embeddedProName] = $proBytes; + } else { + $bytes = Storage::disk('local')->get($file->stored_path); + if ($bytes === null) { + return null; + } + + $embeddedFiles[$embeddedProName] = $bytes; + } + + return [ + 'type' => 'presentation', + 'name' => $displayName, + 'path' => $embeddedProName, + ]; + } + /** * Extract the inner .pro bytes (verbatim) and media files from a .probundle archive. * diff --git a/database/migrations/2026_07_06_100000_add_keyword_to_export_pro_files.php b/database/migrations/2026_07_06_100000_add_keyword_to_export_pro_files.php new file mode 100644 index 0000000..0032568 --- /dev/null +++ b/database/migrations/2026_07_06_100000_add_keyword_to_export_pro_files.php @@ -0,0 +1,22 @@ +string('keyword')->nullable()->after('type'); + }); + } + + public function down(): void + { + Schema::table('export_pro_files', function (Blueprint $table) { + $table->dropColumn('keyword'); + }); + } +}; diff --git a/resources/js/Pages/Settings.vue b/resources/js/Pages/Settings.vue index 32b1fce..af51eac 100644 --- a/resources/js/Pages/Settings.vue +++ b/resources/js/Pages/Settings.vue @@ -18,7 +18,7 @@ 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: [] }) }, + export_pro_files: { type: Object, default: () => ({ prefix: [], postfix: [], keyword: [] }) }, }) const submenus = [ @@ -293,6 +293,7 @@ watch(songPostfixLabelId, (value) => { v-if="activeSubmenu === 'export-files'" :prefix-files="export_pro_files.prefix" :postfix-files="export_pro_files.postfix" + :keyword-files="export_pro_files.keyword" /> diff --git a/resources/js/Pages/Settings/ExportProFiles.vue b/resources/js/Pages/Settings/ExportProFiles.vue index 79c732e..c47231c 100644 --- a/resources/js/Pages/Settings/ExportProFiles.vue +++ b/resources/js/Pages/Settings/ExportProFiles.vue @@ -5,10 +5,13 @@ import { route } from 'ziggy-js' const props = defineProps({ prefixFiles: { type: Array, default: () => [] }, postfixFiles: { type: Array, default: () => [] }, + keywordFiles: { type: Array, default: () => [] }, }) const prefixDragActive = ref(false) const postfixDragActive = ref(false) +const keywordDragActive = ref(false) +const keywordInput = ref('') const uploading = ref(false) const error = ref(null) @@ -16,13 +19,20 @@ function getXsrfToken() { return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '') } -async function uploadFiles(type, files) { +async function uploadFiles(type, files, keyword = null) { if (!files || files.length === 0) return + if (type === 'keyword' && (!keyword || keyword.trim() === '')) { + error.value = 'Bitte gib zuerst ein Schlüsselwort ein.' + return + } uploading.value = true error.value = null const form = new FormData() form.append('type', type) + if (type === 'keyword') { + form.append('keyword', keyword.trim()) + } for (const file of files) { form.append('files[]', file) } @@ -63,6 +73,13 @@ function handlePostfixChange(event) { uploadFiles('postfix', files) } +function handleKeywordChange(event) { + const files = event.target.files + if (!files || files.length === 0) return + event.target.value = '' + uploadFiles('keyword', files, keywordInput.value) +} + function handlePrefixDrop(event) { prefixDragActive.value = false uploadFiles('prefix', event.dataTransfer.files) @@ -73,6 +90,32 @@ function handlePostfixDrop(event) { uploadFiles('postfix', event.dataTransfer.files) } +function handleKeywordDrop(event) { + keywordDragActive.value = false + uploadFiles('keyword', event.dataTransfer.files, keywordInput.value) +} + +async function updateKeyword(id, value) { + error.value = null + try { + const res = await fetch(route('settings.export-pro-files.update-keyword', id), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': getXsrfToken(), + }, + body: JSON.stringify({ keyword: value }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + error.value = data.message || 'Aktualisierung fehlgeschlagen' + } + } catch { + error.value = 'Netzwerkfehler beim Speichern' + } +} + async function deleteFile(id) { try { await fetch(route('settings.export-pro-files.destroy', id), { @@ -95,6 +138,7 @@ async function deleteFile(id) {

Export-Dateien

Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt. + Schlüsselwort-Dateien werden zusätzlich hinter jedem Ablaufpunkt eingefügt, dessen Titel das Schlüsselwort enthält.

@@ -222,5 +266,85 @@ async function deleteFile(id) {

Noch keine Postfix-Dateien hochgeladen.

+ + +
+

Schlüsselwort-Dateien

+

+ Werden beim Export hinter jedem Ablaufpunkt eingefügt, dessen Titel das Schlüsselwort enthält (Groß-/Kleinschreibung egal). +

+ +
+ + +
+ + + +
+
+ + + + {{ file.original_name }} + + +
+
+

Noch keine Schlüsselwort-Dateien hochgeladen.

+
diff --git a/routes/web.php b/routes/web.php index 887dc42..433e384 100644 --- a/routes/web.php +++ b/routes/web.php @@ -130,6 +130,7 @@ */ 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::patch('/settings/export-pro-files/{exportProFile}/keyword', [ExportProFileController::class, 'updateKeyword'])->name('settings.export-pro-files.update-keyword'); 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/ExportProFileKeywordInjectionTest.php b/tests/Feature/ExportProFileKeywordInjectionTest.php new file mode 100644 index 0000000..89d73be --- /dev/null +++ b/tests/Feature/ExportProFileKeywordInjectionTest.php @@ -0,0 +1,166 @@ + $title, + 'ccli_id' => fake()->unique()->numerify('#####'), + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + ]); + + $label = Label::firstOrCreate( + ['name' => 'Verse - '.$title], + ['color' => '#2196F3'], + ); + $section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]); + $section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]); + + return $song; + } + + private function addSongAgendaItem(Service $service, Song $song, string $title, int $sortOrder): void + { + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => $song->title, + 'order' => $sortOrder, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => $title, + 'service_song_id' => $serviceSong->id, + 'sort_order' => $sortOrder, + 'is_before_event' => false, + ]); + } + + private function cleanupTempDir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = scandir($dir); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir.'/'.$item; + is_dir($path) ? $this->cleanupTempDir($path) : unlink($path); + } + + rmdir($dir); + } + + public function test_keyword_datei_wird_direkt_nach_passendem_ablaufpunkt_eingefuegt(): void + { + $service = Service::factory()->create([ + 'title' => 'Keyword Service', + 'date' => now(), + ]); + + // Uppercase title vs lowercase keyword proves case-insensitive substring match. + $this->addSongAgendaItem($service, $this->createSongWithContent('Abendmahlslied'), 'ABENDMAHL feiern', 1); + $this->addSongAgendaItem($service, $this->createSongWithContent('Predigtlied'), 'Predigt', 2); + + $storedPath = 'export-pro-files/keyword/kommunion.pro'; + Storage::disk('local')->put($storedPath, 'kommunion-pro-bytes'); + + ExportProFile::create([ + 'type' => 'keyword', + 'keyword' => 'abendmahl', + 'original_name' => 'kommunion.pro', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries()); + + $abendmahlPos = array_search('Abendmahlslied', $entryNames, true); + $predigtPos = array_search('Predigtlied', $entryNames, true); + $kommunionPositions = array_keys($entryNames, 'kommunion', true); + + $this->assertNotFalse($abendmahlPos, 'Abendmahl song entry not found'); + $this->assertNotFalse($predigtPos, 'Predigt song entry not found'); + $this->assertCount(1, $kommunionPositions, 'Keyword presentation must be injected exactly once'); + + $kommunionPos = $kommunionPositions[0]; + $this->assertSame($abendmahlPos + 1, $kommunionPos, 'Keyword presentation must appear immediately after the matched item'); + $this->assertLessThan($predigtPos, $kommunionPos, 'Keyword presentation must not appear after the non-matching item'); + + $embeddedFiles = $playlist->getEmbeddedFiles(); + $this->assertArrayHasKey('KEYWORD_1_kommunion.pro', $embeddedFiles); + $this->assertSame('kommunion-pro-bytes', $embeddedFiles['KEYWORD_1_kommunion.pro']); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_leeres_keyword_wird_nicht_injiziert(): void + { + $service = Service::factory()->create([ + 'title' => 'Kein Keyword Service', + 'date' => now(), + ]); + + $this->addSongAgendaItem($service, $this->createSongWithContent('Abendmahlslied'), 'Abendmahl feiern', 1); + + $storedPath = 'export-pro-files/keyword/leer.pro'; + Storage::disk('local')->put($storedPath, 'leer-pro-bytes'); + + ExportProFile::create([ + 'type' => 'keyword', + 'keyword' => '', + 'original_name' => 'leer.pro', + 'stored_path' => $storedPath, + 'order' => 1, + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + + $entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries()); + + $this->assertNotContains('leer', $entryNames, 'Files with an empty keyword must never be injected'); + + $this->cleanupTempDir($result['temp_dir']); + } +} diff --git a/tests/Feature/ExportProFileKeywordTest.php b/tests/Feature/ExportProFileKeywordTest.php new file mode 100644 index 0000000..0627884 --- /dev/null +++ b/tests/Feature/ExportProFileKeywordTest.php @@ -0,0 +1,158 @@ +create(); + $file = UploadedFile::fake()->create('kommunion.pro', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'keyword', + 'keyword' => 'Abendmahl', + 'files' => [$file], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('files.0.original_name', 'kommunion.pro'); + + $record = ExportProFile::query()->firstOrFail(); + $this->assertSame('keyword', $record->type); + $this->assertSame('Abendmahl', $record->keyword); + $this->assertTrue(Storage::disk('local')->exists($record->stored_path)); + $this->assertStringContainsString('export-pro-files/keyword', $record->stored_path); + } + + public function test_keyword_upload_ohne_schluesselwort_wird_abgelehnt(): void + { + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('kommunion.pro', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [ + 'type' => 'keyword', + 'files' => [$file], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('keyword'); + $this->assertSame(0, ExportProFile::query()->count()); + } + + public function test_batch_keyword_wird_allen_dateien_zugewiesen(): void + { + $user = User::factory()->create(); + $fileA = UploadedFile::fake()->create('a.pro', 10, 'application/octet-stream'); + $fileB = UploadedFile::fake()->create('b.pro', 10, 'application/octet-stream'); + + $response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [ + 'type' => 'keyword', + 'keyword' => 'Taufe', + 'files' => [$fileA, $fileB], + ]); + + $response->assertStatus(201); + $this->assertSame(2, ExportProFile::keyword()->count()); + $this->assertSame(['Taufe', 'Taufe'], ExportProFile::keyword()->orderBy('order')->pluck('keyword')->all()); + } + + public function test_update_keyword_aktualisiert_schluesselwort(): void + { + $user = User::factory()->create(); + $record = ExportProFile::create([ + 'type' => 'keyword', + 'keyword' => 'Alt', + 'original_name' => 'file.pro', + 'stored_path' => 'export-pro-files/keyword/file.pro', + 'order' => 1, + ]); + + $response = $this->actingAs($user)->patchJson( + route('settings.export-pro-files.update-keyword', $record), + ['keyword' => 'Neu'], + ); + + $response->assertOk(); + $this->assertSame('Neu', $record->fresh()->keyword); + } + + public function test_update_keyword_erfordert_schluesselwort(): void + { + $user = User::factory()->create(); + $record = ExportProFile::create([ + 'type' => 'keyword', + 'keyword' => 'Alt', + 'original_name' => 'file.pro', + 'stored_path' => 'export-pro-files/keyword/file.pro', + 'order' => 1, + ]); + + $response = $this->actingAs($user)->patchJson( + route('settings.export-pro-files.update-keyword', $record), + ['keyword' => ''], + ); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('keyword'); + } + + public function test_update_keyword_fuer_nicht_keyword_datei_wird_abgelehnt(): void + { + $user = User::factory()->create(); + $record = ExportProFile::create([ + 'type' => 'prefix', + 'keyword' => null, + 'original_name' => 'intro.pro', + 'stored_path' => 'export-pro-files/prefix/intro.pro', + 'order' => 1, + ]); + + $response = $this->actingAs($user)->patchJson( + route('settings.export-pro-files.update-keyword', $record), + ['keyword' => 'Abendmahl'], + ); + + $response->assertStatus(422); + $this->assertNull($record->fresh()->keyword); + } + + public function test_settings_index_enthaelt_keyword_dateien(): void + { + $user = User::factory()->create(); + ExportProFile::create([ + 'type' => 'keyword', + 'keyword' => 'Abendmahl', + 'original_name' => 'kommunion.pro', + 'stored_path' => 'export-pro-files/keyword/kommunion.pro', + 'order' => 1, + ]); + + $response = $this->actingAs($user) + ->withoutVite() + ->get(route('settings.index')); + + $response->assertInertia( + fn ($page) => $page + ->component('Settings') + ->has('export_pro_files.keyword', 1) + ->where('export_pro_files.keyword.0.keyword', 'Abendmahl') + ->where('export_pro_files.keyword.0.original_name', 'kommunion.pro') + ); + } +}