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
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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']),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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: () => [] },
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
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