feat(export): keyword-matched custom export files per schedule item

This commit is contained in:
Thorsten Bus 2026-07-06 01:21:45 +02:00
parent e62ea5bf0a
commit 1df04c70e0
10 changed files with 632 additions and 109 deletions

View file

@ -13,7 +13,8 @@ class ExportProFileController extends Controller
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$request->validate([ $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', 'array'],
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'], 'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'],
], [ ], [
@ -21,6 +22,7 @@ public function store(Request $request): JsonResponse
]); ]);
$type = $request->input('type'); $type = $request->input('type');
$keyword = $type === 'keyword' ? $request->input('keyword') : null;
$created = []; $created = [];
foreach ($request->file('files') as $file) { foreach ($request->file('files') as $file) {
@ -29,6 +31,7 @@ public function store(Request $request): JsonResponse
$record = ExportProFile::create([ $record = ExportProFile::create([
'type' => $type, 'type' => $type,
'keyword' => $keyword,
'original_name' => $file->getClientOriginalName(), 'original_name' => $file->getClientOriginalName(),
'stored_path' => $storedPath, 'stored_path' => $storedPath,
'order' => $maxOrder + 1, 'order' => $maxOrder + 1,
@ -47,6 +50,25 @@ public function store(Request $request): JsonResponse
], 201); ], 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 public function destroy(ExportProFile $exportProFile): JsonResponse
{ {
Storage::disk('local')->delete($exportProFile->stored_path); Storage::disk('local')->delete($exportProFile->stored_path);

View file

@ -68,6 +68,7 @@ public function index(): Response
'export_pro_files' => [ 'export_pro_files' => [
'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']), 'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']),
'postfix' => ExportProFile::postfix()->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']),
], ],
]); ]);
} }

View file

@ -9,6 +9,7 @@ class ExportProFile extends Model
{ {
protected $fillable = [ protected $fillable = [
'type', 'type',
'keyword',
'original_name', 'original_name',
'stored_path', 'stored_path',
'order', 'order',
@ -30,4 +31,9 @@ public function scopePostfix(Builder $query): Builder
{ {
return $query->where('type', 'postfix'); return $query->where('type', 'postfix');
} }
public function scopeKeyword(Builder $query): Builder
{
return $query->where('type', 'keyword');
}
} }

View file

@ -67,6 +67,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$realContentEmitted = false; $realContentEmitted = false;
$keyvisualFallbackEmitted = false; $keyvisualFallbackEmitted = false;
$keywordFiles = ExportProFile::keyword()->orderBy('order')->get();
foreach ($agendaItems as $item) { foreach ($agendaItems as $item) {
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
$moderatorName = trim((string) ($moderatorSlideData['text'] ?? '')); $moderatorName = trim((string) ($moderatorSlideData['text'] ?? ''));
@ -107,10 +109,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($this->countSongContentSlides($song) === 0) { if ($this->countSongContentSlides($song) === 0) {
$skippedUnmatched++; $skippedUnmatched++;
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien."; $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
} else {
continue;
}
$proPath = $exportService->generateProFile($song, $service); $proPath = $exportService->generateProFile($song, $service);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename; $destPath = $tempDir.'/'.$proFilename;
@ -126,13 +125,11 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
]; ];
$realContentEmitted = true; $realContentEmitted = true;
}
} else { } else {
$skippedUnmatched++; $skippedUnmatched++;
} }
} else {
continue;
}
$isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon'; $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
if ($isSermon) { if ($isSermon) {
@ -192,6 +189,21 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
} }
} }
foreach ($keywordFiles as $kf) {
$keyword = trim((string) $kf->keyword);
if ($keyword === '') {
continue;
}
if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles);
if ($keywordItem !== null) {
$playlistItems[] = $keywordItem;
}
}
}
}
if (! $announcementInserted && $informationSlides->isNotEmpty()) { if (! $announcementInserted && $informationSlides->isNotEmpty()) {
$prependItems = []; $prependItems = [];
$prependFiles = []; $prependFiles = [];
@ -551,37 +563,54 @@ private function addHeadlineItem(
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
{ {
$prefixEmbedded = [];
$prefixItems = $this->buildExportProItems( $prefixItems = $this->buildExportProItems(
ExportProFile::prefix()->orderBy('order')->get(), ExportProFile::prefix()->orderBy('order')->get(),
'PREFIX', $embeddedFiles,
$prefixEmbedded,
); );
$postfixEmbedded = [];
$postfixItems = $this->buildExportProItems( $postfixItems = $this->buildExportProItems(
ExportProFile::postfix()->orderBy('order')->get(), ExportProFile::postfix()->orderBy('order')->get(),
'POSTFIX', $embeddedFiles,
$postfixEmbedded,
); );
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
$embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded);
} }
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */ /** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
private function buildExportProItems(\Illuminate\Support\Collection $files, string $marker, array &$embedded): array private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array
{ {
$items = []; $items = [];
foreach ($files as $file) { foreach ($files as $file) {
$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)) { if (! Storage::disk('local')->exists($file->stored_path)) {
continue; return null;
} }
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME)); $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedProName = $marker.'_'.$file->order.'_'.$safeBase.'.pro'; $embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME); $displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
if ($ext === 'probundle') { if ($ext === 'probundle') {
@ -589,41 +618,34 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri
$extracted = $this->extractProBundle($storedAbsPath); $extracted = $this->extractProBundle($storedAbsPath);
if ($extracted === null) { if ($extracted === null) {
continue; return null;
} }
[$proBytes, $mediaFiles] = $extracted; [$proBytes, $mediaFiles] = $extracted;
foreach ($mediaFiles as $entryName => $entryBytes) { foreach ($mediaFiles as $entryName => $entryBytes) {
$mediaName = basename((string) $entryName); $mediaName = basename((string) $entryName);
if (! isset($embedded[$mediaName])) { if (! isset($embeddedFiles[$mediaName])) {
$embedded[$mediaName] = $entryBytes; $embeddedFiles[$mediaName] = $entryBytes;
} }
} }
$embedded[$embeddedProName] = $proBytes; $embeddedFiles[$embeddedProName] = $proBytes;
$items[] = [
'type' => 'presentation',
'name' => $displayName,
'path' => $embeddedProName,
];
} else { } else {
$bytes = Storage::disk('local')->get($file->stored_path); $bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) { if ($bytes === null) {
continue; return null;
} }
$embedded[$embeddedProName] = $bytes; $embeddedFiles[$embeddedProName] = $bytes;
$items[] = [ }
return [
'type' => 'presentation', 'type' => 'presentation',
'name' => $displayName, 'name' => $displayName,
'path' => $embeddedProName, 'path' => $embeddedProName,
]; ];
} }
}
return $items;
}
/** /**
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive. * Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.

View file

@ -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('export_pro_files', function (Blueprint $table) {
$table->string('keyword')->nullable()->after('type');
});
}
public function down(): void
{
Schema::table('export_pro_files', function (Blueprint $table) {
$table->dropColumn('keyword');
});
}
};

View file

@ -18,7 +18,7 @@ const props = defineProps({
collections: { type: Array, default: () => [] }, collections: { type: Array, default: () => [] },
last_macros_import: { type: Object, default: () => ({}) }, last_macros_import: { type: Object, default: () => ({}) },
last_labels_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 = [ const submenus = [
@ -293,6 +293,7 @@ watch(songPostfixLabelId, (value) => {
v-if="activeSubmenu === 'export-files'" v-if="activeSubmenu === 'export-files'"
:prefix-files="export_pro_files.prefix" :prefix-files="export_pro_files.prefix"
:postfix-files="export_pro_files.postfix" :postfix-files="export_pro_files.postfix"
:keyword-files="export_pro_files.keyword"
/> />
</div> </div>
</div> </div>

View file

@ -5,10 +5,13 @@ import { route } from 'ziggy-js'
const props = defineProps({ const props = defineProps({
prefixFiles: { type: Array, default: () => [] }, prefixFiles: { type: Array, default: () => [] },
postfixFiles: { type: Array, default: () => [] }, postfixFiles: { type: Array, default: () => [] },
keywordFiles: { type: Array, default: () => [] },
}) })
const prefixDragActive = ref(false) const prefixDragActive = ref(false)
const postfixDragActive = ref(false) const postfixDragActive = ref(false)
const keywordDragActive = ref(false)
const keywordInput = ref('')
const uploading = ref(false) const uploading = ref(false)
const error = ref(null) const error = ref(null)
@ -16,13 +19,20 @@ function getXsrfToken() {
return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '') 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 (!files || files.length === 0) return
if (type === 'keyword' && (!keyword || keyword.trim() === '')) {
error.value = 'Bitte gib zuerst ein Schlüsselwort ein.'
return
}
uploading.value = true uploading.value = true
error.value = null error.value = null
const form = new FormData() const form = new FormData()
form.append('type', type) form.append('type', type)
if (type === 'keyword') {
form.append('keyword', keyword.trim())
}
for (const file of files) { for (const file of files) {
form.append('files[]', file) form.append('files[]', file)
} }
@ -63,6 +73,13 @@ function handlePostfixChange(event) {
uploadFiles('postfix', files) 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) { function handlePrefixDrop(event) {
prefixDragActive.value = false prefixDragActive.value = false
uploadFiles('prefix', event.dataTransfer.files) uploadFiles('prefix', event.dataTransfer.files)
@ -73,6 +90,32 @@ function handlePostfixDrop(event) {
uploadFiles('postfix', event.dataTransfer.files) 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) { async function deleteFile(id) {
try { try {
await fetch(route('settings.export-pro-files.destroy', id), { await fetch(route('settings.export-pro-files.destroy', id), {
@ -95,6 +138,7 @@ async function deleteFile(id) {
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3> <h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt. Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.
Schlüsselwort-Dateien werden zusätzlich hinter jedem Ablaufpunkt eingefügt, dessen Titel das Schlüsselwort enthält.
</p> </p>
</div> </div>
@ -222,5 +266,85 @@ async function deleteFile(id) {
</div> </div>
<p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p> <p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p>
</div> </div>
<!-- Keyword section -->
<div>
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Schlüsselwort-Dateien</h4>
<p class="mb-3 text-xs text-gray-400">
Werden beim Export hinter jedem Ablaufpunkt eingefügt, dessen Titel das Schlüsselwort enthält (Groß-/Kleinschreibung egal).
</p>
<div class="mb-4">
<label class="mb-1 block text-xs font-medium text-gray-600">Schlüsselwort für den Upload</label>
<input
v-model="keywordInput"
type="text"
class="w-full rounded-lg border-gray-200 text-sm shadow-sm focus:border-amber-400 focus:ring-amber-400"
placeholder="z. B. Abendmahl"
data-testid="export-keyword-upload-input"
/>
</div>
<label
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
:class="keywordDragActive
? 'border-amber-400 bg-amber-50'
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
data-testid="export-keyword-dropzone"
@dragover.prevent
@dragenter.prevent="keywordDragActive = true"
@dragleave.prevent="keywordDragActive = false"
@drop.prevent="handleKeywordDrop"
>
<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 / .probundle-Dateien auswählen oder hierher ziehen' }}
</span>
<span class="mt-1 text-xs text-gray-400">Das Schlüsselwort oben wird allen hochgeladenen Dateien zugewiesen</span>
<input
type="file"
class="hidden"
accept=".pro,.probundle"
multiple
:disabled="uploading"
data-testid="export-keyword-file-input"
@change="handleKeywordChange"
/>
</label>
<div v-if="keywordFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100">
<div
v-for="file in keywordFiles"
: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>
<input
type="text"
class="w-40 rounded-lg border-gray-200 text-xs shadow-sm focus:border-amber-400 focus:ring-amber-400"
:value="file.keyword"
placeholder="Schlüsselwort"
:data-testid="'export-keyword-input-' + file.id"
@change="updateKeyword(file.id, $event.target.value)"
@blur="updateKeyword(file.id, $event.target.value)"
/>
<button
class="ml-2 text-gray-400 hover:text-red-500"
:data-testid="'export-keyword-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 Schlüsselwort-Dateien hochgeladen.</p>
</div>
</div> </div>
</template> </template>

View file

@ -130,6 +130,7 @@
*/ */
Route::post('/settings/export-pro-files', [ExportProFileController::class, 'store'])->name('settings.export-pro-files.store'); 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::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/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import');
Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import'); Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import');

View file

@ -0,0 +1,166 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class ExportProFileKeywordInjectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
private function createSongWithContent(string $title): Song
{
$song = Song::create([
'title' => $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']);
}
}

View file

@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ExportProFileKeywordTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
public function test_pro_datei_wird_als_keyword_akzeptiert(): void
{
$user = User::factory()->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')
);
}
}