feat(export): keyword-matched custom export files per schedule item
This commit is contained in:
parent
e62ea5bf0a
commit
1df04c70e0
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 = [];
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
|
||||
<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.
|
||||
Schlüsselwort-Dateien werden zusätzlich hinter jedem Ablaufpunkt eingefügt, dessen Titel das Schlüsselwort enthält.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -222,5 +266,85 @@ async function deleteFile(id) {
|
|||
</div>
|
||||
<p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p>
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
166
tests/Feature/ExportProFileKeywordInjectionTest.php
Normal file
166
tests/Feature/ExportProFileKeywordInjectionTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
158
tests/Feature/ExportProFileKeywordTest.php
Normal file
158
tests/Feature/ExportProFileKeywordTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue