Compare commits
6 commits
7a29a21822
...
ea4b868650
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4b868650 | ||
|
|
1df04c70e0 | ||
|
|
e62ea5bf0a | ||
|
|
1c811a0550 | ||
|
|
e47299f90a | ||
|
|
e849d43b74 |
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function index(): Response
|
|||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item,abspann'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
|
|
@ -50,7 +50,7 @@ public function store(Request $request): JsonResponse
|
|||
public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item,abspann'],
|
||||
'macro_id' => ['sometimes', 'integer', 'exists:macros,id'],
|
||||
'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
// Macro resolution per part type (for icons + Anpassen/Standard panel)
|
||||
$resolver = app(MacroResolutionService::class);
|
||||
$macros_per_part = [];
|
||||
foreach (['information', 'moderation', 'sermon', 'song', 'agenda_item'] as $partType) {
|
||||
foreach (['information', 'moderation', 'sermon', 'song', 'agenda_item', 'abspann'] as $partType) {
|
||||
$assignments = $resolver->resolveAssignmentsForPart($service, $partType);
|
||||
$isOverridden = ServiceMacroOverride::where('service_id', $service->id)
|
||||
->where('part_type', $partType)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class ServiceMacroOverrideController extends Controller
|
|||
public function store(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item,abspann'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::firstOrCreate([
|
||||
|
|
@ -40,7 +40,7 @@ public function store(Request $request, Service $service): JsonResponse
|
|||
public function destroy(Service $service, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item,abspann'],
|
||||
]);
|
||||
|
||||
ServiceMacroOverride::where('service_id', $service->id)
|
||||
|
|
@ -57,7 +57,7 @@ public function destroy(Service $service, Request $request): JsonResponse
|
|||
public function storeAssignment(Request $request, Service $service): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'],
|
||||
'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item,abspann'],
|
||||
'macro_id' => ['required', 'integer', 'exists:macros,id'],
|
||||
'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'],
|
||||
'label_id' => ['nullable', 'integer', 'exists:labels,id'],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
class PlaylistExportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MacroResolutionService $macroResolutionService,
|
||||
) {}
|
||||
|
||||
/** @return array{path: string, filename: string, skipped: int, warnings: array<int, string>} */
|
||||
public function generatePlaylist(Service $service, bool $preview = false): array
|
||||
{
|
||||
|
|
@ -43,16 +47,7 @@ public function generatePlaylist(Service $service, bool $preview = false): array
|
|||
*/
|
||||
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
|
||||
{
|
||||
$informationSlides = Slide::where('type', 'information')
|
||||
->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date))
|
||||
->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id))
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($service->date) {
|
||||
$informationSlides->whereDate('uploaded_at', '<=', $service->date);
|
||||
}
|
||||
|
||||
$informationSlides = $informationSlides->orderBy('sort_order')->orderByDesc('uploaded_at')->get();
|
||||
$informationSlides = $this->informationSlidesFor($service);
|
||||
|
||||
$announcementPatterns = Setting::get('agenda_announcement_position');
|
||||
$announcementInserted = false;
|
||||
|
|
@ -72,10 +67,14 @@ 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'] ?? ''));
|
||||
$moderatorPresentationName = $moderatorName !== '' ? 'Moderator - '.$moderatorName : 'Moderator';
|
||||
$this->writeProAndEmbed(
|
||||
'Moderator',
|
||||
$moderatorPresentationName,
|
||||
$moderatorSlideData,
|
||||
$tempDir,
|
||||
$playlistItems,
|
||||
|
|
@ -110,73 +109,97 @@ 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);
|
||||
|
||||
if ($item->slides->isNotEmpty()) {
|
||||
if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') {
|
||||
$this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt');
|
||||
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
}
|
||||
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),
|
||||
);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isNameTagAgendaItem($item)) {
|
||||
if ($realContentEmitted && ! $keyvisualFallbackEmitted) {
|
||||
$this->addKeyVisualFallbackPresentation(
|
||||
$item,
|
||||
$service,
|
||||
if (count($playlistItems) > $countBefore) {
|
||||
$realContentEmitted = true;
|
||||
}
|
||||
}
|
||||
} 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),
|
||||
);
|
||||
$keyvisualFallbackEmitted = true;
|
||||
} else {
|
||||
$this->addHeadlineItem(
|
||||
$item,
|
||||
$playlistItems,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -205,6 +228,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
|
||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
||||
|
|
@ -318,6 +343,8 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
|||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
|
||||
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
||||
|
|
@ -536,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.
|
||||
*
|
||||
|
|
@ -694,9 +731,9 @@ private function extractProBundle(string $absPath): ?array
|
|||
}
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options);
|
||||
}
|
||||
|
||||
private function buildModeratorSlideData(Service $service): ?array
|
||||
|
|
@ -719,27 +756,151 @@ private function buildPreacherSlideData(Service $service): ?array
|
|||
return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
|
||||
}
|
||||
|
||||
private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void
|
||||
/**
|
||||
* Emit the sermon intro as ONE presentation with up to two cues:
|
||||
* slide 1 = key-visual (image only)
|
||||
* slide 2 = key-visual WITH the preacher name tag rendered on top
|
||||
*
|
||||
* The presentation is named after the preacher ("Prediger - <Name>") when a
|
||||
* name tag is configured, otherwise "Keyvisual-Predigt". Emits nothing when
|
||||
* neither a key-visual nor a name tag is available.
|
||||
*/
|
||||
private function addSermonIntroPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||
{
|
||||
$kvData = $this->keyVisualData($service);
|
||||
if ($kvData === null) {
|
||||
$nameTagData = $this->buildPreacherSlideData($service);
|
||||
|
||||
if ($kvData === null && $nameTagData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->embedKeyVisual($service, $embeddedFiles);
|
||||
$slides = [];
|
||||
|
||||
$slideData = ['imageOnly' => true, 'background' => $kvData];
|
||||
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
|
||||
if ($kvData !== null) {
|
||||
$this->embedKeyVisual($service, $embeddedFiles);
|
||||
$slides[] = ['imageOnly' => true, 'background' => $kvData];
|
||||
}
|
||||
|
||||
if ($nameTagData !== null) {
|
||||
if ($kvData !== null) {
|
||||
$nameTagData['background'] = $kvData;
|
||||
}
|
||||
$slides[] = $nameTagData;
|
||||
|
||||
$preacher = trim((string) ($nameTagData['text'] ?? ''));
|
||||
$name = $preacher !== '' ? 'Prediger - '.$preacher : 'Prediger';
|
||||
} else {
|
||||
$name = 'Keyvisual-Predigt';
|
||||
}
|
||||
|
||||
$groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]];
|
||||
$arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
|
||||
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro';
|
||||
$path = $tempDir.'/'.$filename;
|
||||
$this->writeProFile($path, $name, $groups, $arrangements);
|
||||
$embeddedFiles[$filename] = file_get_contents($path);
|
||||
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
|
||||
}
|
||||
|
||||
private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||
/**
|
||||
* Information slides visible for the given service: type=information, not soft-deleted,
|
||||
* global or this-service, unexpired at (and uploaded on/before) the service date.
|
||||
*
|
||||
* @return Collection<int, Slide>
|
||||
*/
|
||||
private function informationSlidesFor(Service $service): Collection
|
||||
{
|
||||
$slideData = $this->buildPreacherSlideData($service);
|
||||
if ($slideData === null) {
|
||||
$query = Slide::where('type', 'information')
|
||||
->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date))
|
||||
->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id))
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($service->date) {
|
||||
$query->whereDate('uploaded_at', '<=', $service->date);
|
||||
}
|
||||
|
||||
return $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the closing "Abspann" presentation as the very last playlist item.
|
||||
*
|
||||
* Slides: key-visual (first) followed by ALL information slides. Every cue
|
||||
* auto-advances after 10s; the last cue loops back to the first ("auto repeat
|
||||
* all"). The presentation uses a Dissolve transition and each cue carries any
|
||||
* configured 'abspann' macro. Emits nothing when there is neither a key-visual
|
||||
* nor an information slide.
|
||||
*/
|
||||
private function addAbspannPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||
{
|
||||
$informationSlides = $this->informationSlidesFor($service);
|
||||
$kvData = $this->keyVisualData($service);
|
||||
|
||||
if ($kvData === null && $informationSlides->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles);
|
||||
$slides = [];
|
||||
|
||||
if ($kvData !== null) {
|
||||
$this->embedKeyVisual($service, $embeddedFiles);
|
||||
$slides[] = [
|
||||
'imageOnly' => true,
|
||||
'background' => $kvData,
|
||||
'completion' => ['time' => 10.0, 'target' => 'next'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($informationSlides->values() as $index => $slide) {
|
||||
$storedPath = Storage::disk('public')->path($slide->stored_filename);
|
||||
|
||||
if (! file_exists($storedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageFilename = 'abspann_'.($index + 1).'_'.basename($slide->stored_filename);
|
||||
$destPath = $tempDir.'/'.$imageFilename;
|
||||
copy($storedPath, $destPath);
|
||||
|
||||
$embeddedFiles[$imageFilename] = file_get_contents($destPath);
|
||||
|
||||
$slides[] = [
|
||||
'media' => $imageFilename,
|
||||
'format' => 'JPG',
|
||||
'label' => $slide->original_filename,
|
||||
'bundleRelative' => true,
|
||||
'completion' => ['time' => 10.0, 'target' => 'next'],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($slides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$total = count($slides);
|
||||
foreach ($slides as $i => &$sd) {
|
||||
$macros = $this->macroResolutionService->macrosForSlide($service, 'abspann', [
|
||||
'index' => $i,
|
||||
'total' => $total,
|
||||
'label_id' => null,
|
||||
]);
|
||||
if (! empty($macros)) {
|
||||
$sd['macro'] = $macros[0];
|
||||
}
|
||||
}
|
||||
unset($sd);
|
||||
|
||||
$slides[$total - 1]['completion']['target'] = 'first';
|
||||
|
||||
$name = 'Abspann';
|
||||
$groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]];
|
||||
$arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
|
||||
|
||||
$filename = 'Abspann-'.uniqid().'.pro';
|
||||
$path = $tempDir.'/'.$filename;
|
||||
$this->writeProFile($path, $name, $groups, $arrangements, ['transition' => 'dissolve', 'transitionDuration' => 1.0]);
|
||||
$embeddedFiles[$filename] = file_get_contents($path);
|
||||
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
|
||||
}
|
||||
|
||||
private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Relax the `part_type` enum to a plain string so any controller-validated
|
||||
* value (including the new 'abspann' part type) is accepted across MySQL and
|
||||
* SQLite. The enum previously rejected 'abspann' via a MySQL enum definition
|
||||
* or a SQLite check-constraint.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('macro_assignments', function (Blueprint $table) {
|
||||
$table->string('part_type')->change();
|
||||
});
|
||||
|
||||
Schema::table('service_macro_overrides', function (Blueprint $table) {
|
||||
$table->string('part_type')->change();
|
||||
});
|
||||
|
||||
Schema::table('service_macro_assignments', function (Blueprint $table) {
|
||||
$table->string('part_type')->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort restore of the original enum. Any 'abspann' rows would be lost
|
||||
* on downgrade; a lossy down is acceptable for this local/dev project.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$original = ['information', 'moderation', 'sermon', 'song', 'agenda_item'];
|
||||
|
||||
Schema::table('macro_assignments', function (Blueprint $table) use ($original) {
|
||||
$table->enum('part_type', $original)->change();
|
||||
});
|
||||
|
||||
Schema::table('service_macro_overrides', function (Blueprint $table) use ($original) {
|
||||
$table->enum('part_type', $original)->change();
|
||||
});
|
||||
|
||||
Schema::table('service_macro_assignments', function (Blueprint $table) use ($original) {
|
||||
$table->enum('part_type', $original)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const parts = [
|
|||
{ key: 'sermon', label: 'Predigt' },
|
||||
{ key: 'song', label: 'Lieder' },
|
||||
{ key: 'agenda_item', label: 'Agenda-Items' },
|
||||
{ key: 'abspann', label: 'Abspann' },
|
||||
]
|
||||
|
||||
const positions = [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
225
tests/Feature/AbspannExportTest.php
Normal file
225
tests/Feature/AbspannExportTest.php
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Slide;
|
||||
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 AbspannExportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
}
|
||||
|
||||
public function test_abspann_is_last_item_with_keyvisual_first_then_information_slides(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
|
||||
Storage::disk('public')->put('slides/info1.jpg', 'info-image-1');
|
||||
Storage::disk('public')->put('slides/info2.jpg', 'info-image-2');
|
||||
|
||||
$service = $this->serviceWithSong('Abspann Service', 'slides/kv.jpg');
|
||||
$this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0);
|
||||
$this->createInfoSlide('info2.jpg', 'slides/info2.jpg', 1);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$entries = $playlist->getEntries();
|
||||
$names = array_map(fn ($entry) => $entry->getName(), $entries);
|
||||
|
||||
// Abspann is the very last playlist item and appears exactly once.
|
||||
$last = end($entries);
|
||||
$this->assertSame('Abspann', $last->getName(), 'Abspann must be the last playlist item');
|
||||
$this->assertSame(1, count(array_filter($names, fn ($n) => $n === 'Abspann')));
|
||||
|
||||
// The embedded Abspann .pro has 1 (keyvisual) + 2 (information) = 3 cues.
|
||||
$abspann = $playlist->getEmbeddedSong($last->getDocumentFilename());
|
||||
$this->assertNotNull($abspann);
|
||||
$slides = $this->allParserSlides($abspann);
|
||||
$this->assertCount(3, $slides);
|
||||
|
||||
// First cue is the key-visual (image-only background media).
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
|
||||
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||
$this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media');
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
// Remaining cues are the information slides (foreground media, labelled by filename).
|
||||
$this->assertTrue($slides[1]->hasMedia());
|
||||
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
||||
$this->assertSame('info1.jpg', $slides[1]->getLabel());
|
||||
$this->assertTrue($slides[2]->hasMedia());
|
||||
$this->assertSame('info2.jpg', $slides[2]->getLabel());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into
|
||||
* the slideData by addAbspannPresentation and are covered by the parser repo's
|
||||
* in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted
|
||||
* here: the parser's generated protobuf descriptor does not (yet) register the
|
||||
* Cue.completion_* and Presentation.transition fields, so serializeToString()
|
||||
* drops them on the disk round-trip that every export performs. Once the parser
|
||||
* stubs are regenerated these become observable on the exported .proplaylist and
|
||||
* this note can be replaced with real assertions.
|
||||
*/
|
||||
public function test_abspann_cues_carry_configured_abspann_macro(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
|
||||
Storage::disk('public')->put('slides/info1.jpg', 'info-image-1');
|
||||
|
||||
$macro = Macro::factory()->create(['name' => 'Abspann Macro']);
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'abspann',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$service = $this->serviceWithSong('Abspann Macro Service', 'slides/kv.jpg');
|
||||
$this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$entries = $playlist->getEntries();
|
||||
$last = end($entries);
|
||||
|
||||
$abspann = $playlist->getEmbeddedSong($last->getDocumentFilename());
|
||||
$slides = $this->allParserSlides($abspann);
|
||||
$this->assertCount(2, $slides);
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$this->assertTrue($slide->hasMacro(), 'Every Abspann cue must carry the abspann macro');
|
||||
$this->assertSame('Abspann Macro', $slide->getMacroName());
|
||||
}
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_no_abspann_when_no_keyvisual_and_no_information_slides(): void
|
||||
{
|
||||
// A service with a song but no key-visual and no information slides.
|
||||
$service = $this->serviceWithSong('Ohne Abspann', null);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||
|
||||
$this->assertNotContains('Abspann', $names, 'No Abspann item without key-visual and information slides');
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
private function serviceWithSong(string $title, ?string $keyVisual): Service
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => $title,
|
||||
'date' => now(),
|
||||
'key_visual_filename' => $keyVisual,
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent($title.' Lied');
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => $title.' Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => $title.' Lied',
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function createInfoSlide(string $original, string $stored, int $sortOrder): Slide
|
||||
{
|
||||
return Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'service_id' => null,
|
||||
'original_filename' => $original,
|
||||
'stored_filename' => $stored,
|
||||
'sort_order' => $sortOrder,
|
||||
'expire_date' => null,
|
||||
'uploaded_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 1 - '.$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 allParserSlides(\ProPresenter\Parser\Song $parserSong): array
|
||||
{
|
||||
$slides = [];
|
||||
|
||||
foreach ($parserSong->getGroups() as $group) {
|
||||
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
|
||||
$slides[] = $slide;
|
||||
}
|
||||
}
|
||||
|
||||
return $slides;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,10 +67,11 @@ public function test_playlist_export_setzt_bundle_relative_auf_info_folien_slide
|
|||
|
||||
public function __construct(array &$captured)
|
||||
{
|
||||
parent::__construct(app(\App\Services\MacroResolutionService::class));
|
||||
$this->captured = &$captured;
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
$this->captured[] = $groups;
|
||||
file_put_contents($path, 'mock-pro:'.$name);
|
||||
|
|
|
|||
|
|
@ -106,11 +106,12 @@ public function test_vorab_hinzugefuegtes_probundle_ergibt_nicht_leere_playlist(
|
|||
|
||||
public function __construct(array &$items, array &$files)
|
||||
{
|
||||
parent::__construct(app(\App\Services\MacroResolutionService::class));
|
||||
$this->items = &$items;
|
||||
$this->files = &$files;
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
file_put_contents($path, 'mock-pro:'.$name);
|
||||
}
|
||||
|
|
|
|||
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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,9 +71,9 @@ private function createNormalSong(string $title = 'Normales Lied'): Song
|
|||
|
||||
private function testable_export_service(): PlaylistExportService
|
||||
{
|
||||
return new class extends PlaylistExportService
|
||||
return new class(app(\App\Services\MacroResolutionService::class)) extends PlaylistExportService
|
||||
{
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
file_put_contents($path, 'mock-pro:'.$name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,23 +94,20 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||
|
||||
$moderatorIndex = array_search('Moderator', $names, true);
|
||||
$moderatorIndex = array_search('Moderator - Moderator Max', $names, true);
|
||||
$songIndex = array_search('Großer Gott', $names, true);
|
||||
$kvIndex = array_search('Keyvisual-Predigt', $names, true);
|
||||
$preacherIndex = array_search('Predigername', $names, true);
|
||||
$introIndex = array_search('Prediger - Pastor Paul', $names, true);
|
||||
$sermonIndex = array_search('Predigt', $names, true);
|
||||
|
||||
$this->assertNotFalse($moderatorIndex, 'Moderator nametag missing');
|
||||
$this->assertNotFalse($songIndex, 'Song presentation missing');
|
||||
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing');
|
||||
$this->assertNotFalse($preacherIndex, 'Predigername (preacher nametag) missing');
|
||||
$this->assertNotFalse($introIndex, 'Prediger intro (keyvisual + nametag) missing');
|
||||
$this->assertNotFalse($sermonIndex, 'Predigt (sermon slides) missing');
|
||||
|
||||
$this->assertSame(0, $moderatorIndex, 'Moderator nametag must be first');
|
||||
$this->assertLessThan($songIndex, $moderatorIndex);
|
||||
$this->assertLessThan($kvIndex, $songIndex);
|
||||
$this->assertLessThan($preacherIndex, $kvIndex, 'Keyvisual must come before preacher nametag');
|
||||
$this->assertLessThan($sermonIndex, $preacherIndex, 'Preacher nametag must come before sermon slides');
|
||||
$this->assertLessThan($introIndex, $songIndex);
|
||||
$this->assertLessThan($sermonIndex, $introIndex, 'Prediger intro must come before sermon slides');
|
||||
|
||||
$songParser = $playlist->getEmbeddedSong('Großer Gott.pro');
|
||||
$this->assertNotNull($songParser, 'Embedded song .pro missing');
|
||||
|
|
|
|||
|
|
@ -48,8 +48,11 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi
|
|||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
// Content-less item before any real content → HEADER playlist item (no embedded .pro)
|
||||
$this->assertCount(0, $playlist->getEmbeddedProFiles());
|
||||
// Content-less item before any real content → HEADER playlist item (no embedded .pro
|
||||
// of its own). The only embedded .pro is the trailing looping Abspann key-visual.
|
||||
$proFiles = $playlist->getEmbeddedProFiles();
|
||||
$this->assertCount(1, $proFiles);
|
||||
$this->assertStringStartsWith('Abspann-', array_key_first($proFiles));
|
||||
$this->assertSame($slideCountBefore, Slide::count());
|
||||
|
||||
// Playlist has a header entry named 'Begrüßung'
|
||||
|
|
@ -62,8 +65,8 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi
|
|||
}
|
||||
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung');
|
||||
|
||||
// No KEY_VISUAL.jpg embedded (header has no background media)
|
||||
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
// The key-visual is embedded via the trailing Abspann presentation.
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
|
@ -96,7 +99,8 @@ public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
|
|||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||
// The song .pro plus the trailing looping Abspann key-visual presentation.
|
||||
$this->assertCount(2, $playlist->getEmbeddedProFiles());
|
||||
$this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro'));
|
||||
$this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro'));
|
||||
|
||||
|
|
@ -136,7 +140,8 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
||||
|
||||
$this->assertCount(2, $playlist->getEmbeddedProFiles());
|
||||
// Keyvisual-Predigt intro + Predigt sermon slides + trailing looping Abspann.
|
||||
$this->assertCount(3, $playlist->getEmbeddedProFiles());
|
||||
$this->assertNotNull($sermonSong);
|
||||
|
||||
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||
|
|
|
|||
|
|
@ -30,6 +30,39 @@
|
|||
expect(MacroAssignment::first()->part_type)->toBe('song');
|
||||
});
|
||||
|
||||
test('store creates abspann macro assignment', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macro-assignments.store'), [
|
||||
'part_type' => 'abspann',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)->assertJson(['success' => true]);
|
||||
expect(MacroAssignment::where('part_type', 'abspann')->count())->toBe(1);
|
||||
expect(MacroAssignment::first()->part_type)->toBe('abspann');
|
||||
});
|
||||
|
||||
test('store rejects invalid part_type', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('settings.macro-assignments.store'), [
|
||||
'part_type' => 'bogus',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'all_slides',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect(MacroAssignment::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('store with by_label position and label_id', function () {
|
||||
$user = User::factory()->create();
|
||||
$macro = Macro::factory()->create();
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ private function createSlide(array $attributes): Slide
|
|||
|
||||
private function createTestableExportService(): PlaylistExportService
|
||||
{
|
||||
return new class () extends PlaylistExportService {
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
return new class(app(\App\Services\MacroResolutionService::class)) extends PlaylistExportService
|
||||
{
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
|
||||
{
|
||||
file_put_contents($path, 'mock-pro-file:'.$name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,25 +55,31 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
|
|||
$entries = $playlist->getEntries();
|
||||
|
||||
$names = $this->entryNames($playlist);
|
||||
$offset = $names[0] === 'Moderator' ? 1 : 0;
|
||||
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset));
|
||||
// A moderator name tag may precede the sermon intro when the (factory-random)
|
||||
// responsible person resolves to a moderator; skip it if present.
|
||||
$offset = str_starts_with((string) ($names[0] ?? ''), 'Moderator') ? 1 : 0;
|
||||
// A looping "Abspann" (key-visual) presentation is appended as the last item.
|
||||
$this->assertSame(['Prediger - Erika Predigt', 'Predigt', 'Abspann'], array_slice($names, $offset));
|
||||
|
||||
$keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]);
|
||||
$this->assertCount(1, $keyVisualSlides);
|
||||
$this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl());
|
||||
// The sermon intro is ONE presentation with two cues: the key-visual alone,
|
||||
// then the same key-visual with the preacher name tag rendered on top.
|
||||
$introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
|
||||
$this->assertCount(2, $introSlides);
|
||||
|
||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
|
||||
$this->assertCount(1, $nameTagSlides);
|
||||
// Name and role are now split: the name is the main (\fs84) run and the
|
||||
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
||||
// Name and role are split: the name is the main (\fs84) run and the
|
||||
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
|
||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($nameTagSlides[0]);
|
||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||
$this->assertSame('Erika Predigt', $name);
|
||||
$this->assertSame('Predigt', $subtitle);
|
||||
$this->assertTrue($nameTagSlides[0]->hasMacro());
|
||||
$this->assertTrue($introSlides[1]->hasMacro());
|
||||
|
||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
|
||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
|
||||
$this->assertCount(2, $sermonSlides);
|
||||
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
|
||||
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
|
||||
|
|
@ -117,7 +123,7 @@ public function test_moderator_nametag_is_first_presentation_for_first_visible_a
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$entries = $playlist->getEntries();
|
||||
|
||||
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
|
||||
$this->assertSame(['Moderator - Max Moderation', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
|
||||
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
|
||||
$this->assertCount(1, $moderatorSlides);
|
||||
// Name and role are split: the name is the main run, the role the subtitle run.
|
||||
|
|
@ -156,7 +162,8 @@ public function test_without_macro_configured_no_nametags_are_added_and_sermon_s
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$entries = $playlist->getEntries();
|
||||
|
||||
$this->assertSame(['Keyvisual-Predigt', 'Predigt'], $this->entryNames($playlist));
|
||||
// A looping "Abspann" (key-visual) presentation is appended as the last item.
|
||||
$this->assertSame(['Keyvisual-Predigt', 'Predigt', 'Abspann'], $this->entryNames($playlist));
|
||||
$this->assertCount(1, $this->slidesForEntry($playlist, $entries[0]));
|
||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[1]);
|
||||
$this->assertCount(2, $sermonSlides);
|
||||
|
|
@ -203,6 +210,100 @@ public function test_without_moderator_name_no_moderator_nametag_is_added(): voi
|
|||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_nametag_presentations_are_named_by_role_and_person(): void
|
||||
{
|
||||
$this->configureNameTagMacro();
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Rollen Benennung',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||
'moderator_name' => 'Max Moderation',
|
||||
'preacher_name_override' => 'Erika Predigt',
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent('Eröffnungslied');
|
||||
$serviceSong = ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Eröffnungslied',
|
||||
'order' => 1,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Eröffnungslied',
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
$sermonItem = ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Predigt',
|
||||
'service_song_id' => null,
|
||||
'sort_order' => 2,
|
||||
'is_before_event' => false,
|
||||
]);
|
||||
$this->createSermonSlide($service, $sermonItem, 'sermon.jpg', 0);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$names = $this->entryNames($playlist);
|
||||
|
||||
$this->assertContains('Moderator - Max Moderation', $names);
|
||||
$this->assertContains('Prediger - Erika Predigt', $names);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_no_images_presentation(): void
|
||||
{
|
||||
$this->configureNameTagMacro();
|
||||
Setting::set('agenda_sermon_matching', 'Predigt');
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Predigt ohne Folien',
|
||||
'date' => now(),
|
||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||
'preacher_name_override' => 'Erika Predigt',
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Predigt',
|
||||
'service_song_id' => null,
|
||||
'sort_order' => 1,
|
||||
'is_before_event' => false,
|
||||
'responsible' => [],
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$entries = $playlist->getEntries();
|
||||
|
||||
// A sermon item with no uploaded slides emits ONLY the intro presentation,
|
||||
// plus the looping "Abspann" (key-visual) presentation appended as the last item.
|
||||
$this->assertSame(['Prediger - Erika Predigt', 'Abspann'], $this->entryNames($playlist));
|
||||
|
||||
// The intro .pro carries both cues: key-visual alone + key-visual with name tag.
|
||||
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
|
||||
$this->assertCount(2, $introSlides);
|
||||
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
|
||||
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
|
||||
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
|
||||
$this->assertSame('Erika Predigt', $name);
|
||||
$this->assertSame('Predigt', $subtitle);
|
||||
$this->assertTrue($introSlides[1]->hasMacro());
|
||||
|
||||
// No separate sermon-images presentation is emitted.
|
||||
$this->assertNull($playlist->getEmbeddedSong('Predigt.pro'));
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
private function configureNameTagMacro(): void
|
||||
{
|
||||
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
|
||||
|
|
|
|||
|
|
@ -45,8 +45,10 @@
|
|||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Destruktive Migration');
|
||||
|
||||
// The guarded migration (create_song_sections_and_rescope_slides) is 3rd from the top
|
||||
// of the migration stack (2 newer migrations were added after it).
|
||||
// Rolling back 3 steps reaches the destructive guard and triggers the RuntimeException.
|
||||
Artisan::call('migrate:rollback', ['--step' => 3]);
|
||||
// create_song_sections_and_rescope_slides is a destructive migration whose down()
|
||||
// throws by design. RefreshDatabase applies every migration in a single batch, so a
|
||||
// full-batch rollback proceeds newest -> oldest and reaches the guard before touching
|
||||
// anything older. This stays correct no matter how many migrations are added above the
|
||||
// guard later: they simply roll back first, then the guard throws the RuntimeException.
|
||||
Artisan::call('migrate:rollback');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue