fix: login redirect, nametag/worship resolution, macros, export headers & probundle
- Post-login (OAuth + dev-login) now redirects to the next upcoming service's edit page instead of /dashboard, mirroring the GET / route. - NameTagResolver now reads the real ChurchTools `responsible` shape (persons[].person.title) and resolves moderator/preacher/worship-leader by responsible ROLE ([Moderation]/[Predigt]/[Lobpreis]). This fixes missing name slides and makes the worship-leader arrangement trigger (e.g. service 12 → "Benedikt Hardt" / "Jennifer Schneider"). - NameTagSlideBuilder no longer silently drops the name slide when the configured macro id points to a missing macro; it emits the slide without a macro instead. - Song export: the "first slide" / "last slide" macro now applies only to the song's very first/last slide (global slide index across all sections), not the first slide of every section. - Export "headlines" for content-less agenda items are now emitted as proper ProPresenter playlist HEADER items instead of text presentations. - Prefix/postfix export files now also accept .probundle (unzipped: inner .pro + media embedded) in addition to .pro, both for upload validation and export injection. Full suite green (587 passed).
This commit is contained in:
parent
e33418f716
commit
d5f3990f3b
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -52,7 +53,12 @@ public function callback(): RedirectResponse
|
|||
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
$service = Service::nextUpcoming()->first();
|
||||
$target = $service
|
||||
? route('services.edit', $service->id)
|
||||
: route('services.index');
|
||||
|
||||
return redirect()->intended($target);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ public function store(Request $request): JsonResponse
|
|||
$request->validate([
|
||||
'type' => ['required', Rule::in(['prefix', 'postfix'])],
|
||||
'files' => ['required', 'array'],
|
||||
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro'],
|
||||
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'],
|
||||
], [
|
||||
'files.*.extensions' => 'Nur .pro- und .probundle-Dateien sind erlaubt.',
|
||||
]);
|
||||
|
||||
$type = $request->input('type');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ public function moderatorFor(Service $service): ?string
|
|||
return $override;
|
||||
}
|
||||
|
||||
// Prefer the person whose responsible role is "Moderation".
|
||||
$byRole = $this->nameForRole($service, 'moderation');
|
||||
if ($byRole !== null) {
|
||||
return $byRole;
|
||||
}
|
||||
|
||||
$firstAgendaItem = $service->agendaItems()
|
||||
->where('is_before_event', false)
|
||||
->orderBy('sort_order')
|
||||
|
|
@ -32,14 +38,7 @@ public function moderatorFor(Service $service): ?string
|
|||
|
||||
public function worshipLeaderFor(Service $service): ?string
|
||||
{
|
||||
$worshipItem = $service->agendaItems()
|
||||
->where('is_before_event', false)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (ServiceAgendaItem $item) => $this->isWorshipItem($item));
|
||||
|
||||
return $worshipItem ? $this->namesFromResponsible($worshipItem->responsible) : null;
|
||||
return $this->nameForRole($service, 'lobpreis');
|
||||
}
|
||||
|
||||
public function preacherFor(Service $service): ?string
|
||||
|
|
@ -54,6 +53,12 @@ public function preacherFor(Service $service): ?string
|
|||
return $preacherName;
|
||||
}
|
||||
|
||||
// Prefer the person whose responsible role is "Predigt".
|
||||
$byRole = $this->nameForRole($service, 'predigt');
|
||||
if ($byRole !== null) {
|
||||
return $byRole;
|
||||
}
|
||||
|
||||
$sermonItem = $service->agendaItems()
|
||||
->where('is_before_event', false)
|
||||
->whereNull('service_song_id')
|
||||
|
|
@ -65,6 +70,29 @@ public function preacherFor(Service $service): ?string
|
|||
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first visible agenda item carrying a responsible person whose
|
||||
* role (persons[].service, e.g. "[Lobpreis]") matches $roleNeedle, and
|
||||
* return that person's display name.
|
||||
*/
|
||||
private function nameForRole(Service $service, string $roleNeedle): ?string
|
||||
{
|
||||
$items = $service->agendaItems()
|
||||
->where('is_before_event', false)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$name = $this->nameFromResponsibleByRole($item->responsible, $roleNeedle);
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function filledString(?string $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) $value);
|
||||
|
|
@ -78,10 +106,17 @@ private function namesFromResponsible(mixed $responsible): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
|
||||
// Real CTS shape: { "text": "...", "persons": [ { "service": "[Lobpreis]", "person": { "title": "...", ... } } ] }
|
||||
if (array_key_exists('persons', $responsible)) {
|
||||
$entries = $responsible['persons'];
|
||||
} elseif (Arr::isAssoc($responsible)) {
|
||||
$entries = [$responsible];
|
||||
} else {
|
||||
$entries = $responsible;
|
||||
}
|
||||
|
||||
$names = collect($people)
|
||||
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
|
||||
$names = collect($entries)
|
||||
->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
|
@ -89,35 +124,94 @@ private function namesFromResponsible(mixed $responsible): ?string
|
|||
return $names === [] ? null : implode(', ', $names);
|
||||
}
|
||||
|
||||
private function nameFromResponsiblePerson(mixed $person): ?string
|
||||
/**
|
||||
* Extract a person name from a responsible entry.
|
||||
* Handles both the real CTS shape (entry has a 'person' sub-key) and the
|
||||
* legacy/test shape (entry is a flat array with 'name'/'firstName'/'lastName' or a string).
|
||||
*/
|
||||
private function nameFromResponsibleEntry(mixed $entry): ?string
|
||||
{
|
||||
if (is_string($person)) {
|
||||
return $this->filledString($person);
|
||||
if (is_string($entry)) {
|
||||
return $this->filledString($entry);
|
||||
}
|
||||
|
||||
if (! is_array($person)) {
|
||||
if (! is_array($entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->filledString($person['name'] ?? null);
|
||||
// Real CTS shape: { "service": "[Lobpreis]", "person": { "title": "Kornelius Weiß", ... } }
|
||||
if (isset($entry['person']) && is_array($entry['person'])) {
|
||||
return $this->nameFromPersonNode($entry['person']);
|
||||
}
|
||||
|
||||
// Legacy / test shape: { "name": "Anna Müller" } or { "firstName": "...", "lastName": "..." }
|
||||
return $this->nameFromPersonNode($entry);
|
||||
}
|
||||
|
||||
/** Extract display name from a person node (title preferred, then firstName+lastName). */
|
||||
private function nameFromPersonNode(mixed $node): ?string
|
||||
{
|
||||
if (! is_array($node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = $this->filledString($node['title'] ?? null);
|
||||
if ($title !== null) {
|
||||
return $title;
|
||||
}
|
||||
|
||||
$name = $this->filledString($node['name'] ?? null);
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
|
||||
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
|
||||
$domainAttrs = $node['domainAttributes'] ?? [];
|
||||
$firstName = $this->filledString(
|
||||
$node['firstName'] ?? $node['first_name'] ?? ($domainAttrs['firstName'] ?? null)
|
||||
) ?? '';
|
||||
$lastName = $this->filledString(
|
||||
$node['lastName'] ?? $node['last_name'] ?? ($domainAttrs['lastName'] ?? null)
|
||||
) ?? '';
|
||||
$fullName = trim($firstName.' '.$lastName);
|
||||
|
||||
return $fullName === '' ? null : $fullName;
|
||||
}
|
||||
|
||||
private function isWorshipItem(ServiceAgendaItem $item): bool
|
||||
/**
|
||||
* Scan a responsible array for a person whose role (persons[].service)
|
||||
* contains $roleNeedle (e.g. 'lobpreis', 'predigt', 'moderation').
|
||||
* Returns the person's display name, or null if none found.
|
||||
*/
|
||||
private function nameFromResponsibleByRole(mixed $responsible, string $roleNeedle): ?string
|
||||
{
|
||||
$title = Str::lower($item->title);
|
||||
$type = Str::lower($item->type ?? '');
|
||||
if (! is_array($responsible) || $responsible === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_contains($title, 'lobpreis')
|
||||
|| str_contains($type, 'lobpreis');
|
||||
// Real CTS shape: { "persons": [ { "service": "[Lobpreis]", "person": { ... } } ] }
|
||||
$entries = array_key_exists('persons', $responsible) ? $responsible['persons'] : [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = $this->normalizeRole($entry['service'] ?? '');
|
||||
if ($role !== '' && str_contains($role, $roleNeedle)) {
|
||||
$name = $this->nameFromResponsibleEntry($entry);
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip brackets, whitespace, and lowercase a role string for comparison. */
|
||||
private function normalizeRole(string $role): string
|
||||
{
|
||||
return Str::lower(preg_replace('/[^a-zA-ZäöüÄÖÜß]/', '', $role) ?? '');
|
||||
}
|
||||
|
||||
private function isSermonItem(ServiceAgendaItem $item): bool
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ public function build(string $name, string $title): ?array
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
// macro_id is configured but the Macro record is missing — still return a slide
|
||||
// without a macro so the name tag is not silently dropped.
|
||||
return ['text' => $name."\n".$title];
|
||||
}
|
||||
|
||||
$macroName = Setting::get('namenseinblender_macro_name');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||
use ZipArchive;
|
||||
|
||||
class PlaylistExportService
|
||||
{
|
||||
|
|
@ -169,11 +170,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
);
|
||||
$keyvisualFallbackEmitted = true;
|
||||
} else {
|
||||
$this->addHeadlinePresentation(
|
||||
$this->addHeadlineItem(
|
||||
$item,
|
||||
$tempDir,
|
||||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -201,7 +200,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
|
@ -309,7 +308,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
|
@ -514,99 +513,118 @@ private function addKeyVisualFallbackPresentation(
|
|||
];
|
||||
}
|
||||
|
||||
private function addHeadlinePresentation(
|
||||
private function addHeadlineItem(
|
||||
ServiceAgendaItem $item,
|
||||
string $tempDir,
|
||||
array &$playlistItems,
|
||||
array &$embeddedFiles,
|
||||
): void {
|
||||
$label = $item->title ?: 'Ablaufpunkt';
|
||||
$slideData = ['text' => $label];
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => $label,
|
||||
'color' => [0, 0, 0, 1],
|
||||
'slides' => [$slideData],
|
||||
],
|
||||
];
|
||||
$arrangements = [
|
||||
[
|
||||
'name' => 'normal',
|
||||
'groupNames' => [$label],
|
||||
],
|
||||
];
|
||||
|
||||
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
|
||||
$proFilename = $safeLabel.'-headline-'.uniqid().'.pro';
|
||||
$proPath = $tempDir.'/'.$proFilename;
|
||||
|
||||
$this->writeProFile($proPath, $label, $groups, $arrangements);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($proPath);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => $label,
|
||||
'path' => $proFilename,
|
||||
'type' => 'header',
|
||||
'name' => $item->title ?: 'Ablaufpunkt',
|
||||
'color' => [0.5, 0.5, 0.5, 1.0],
|
||||
];
|
||||
}
|
||||
|
||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, string $tempDir): void
|
||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
|
||||
{
|
||||
$prefixFiles = ExportProFile::prefix()->orderBy('order')->get();
|
||||
$postfixFiles = ExportProFile::postfix()->orderBy('order')->get();
|
||||
|
||||
$prefixItems = [];
|
||||
$prefixEmbedded = [];
|
||||
$prefixItems = $this->buildExportProItems(
|
||||
ExportProFile::prefix()->orderBy('order')->get(),
|
||||
'PREFIX',
|
||||
$prefixEmbedded,
|
||||
);
|
||||
|
||||
foreach ($prefixFiles as $file) {
|
||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($bytes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||
$embeddedFilename = 'PREFIX_'.$file->order.'_'.$safeBase.'.pro';
|
||||
$prefixEmbedded[$embeddedFilename] = $bytes;
|
||||
$prefixItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
|
||||
'path' => $embeddedFilename,
|
||||
];
|
||||
}
|
||||
|
||||
$postfixItems = [];
|
||||
$postfixEmbedded = [];
|
||||
|
||||
foreach ($postfixFiles as $file) {
|
||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($bytes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||
$embeddedFilename = 'POSTFIX_'.$file->order.'_'.$safeBase.'.pro';
|
||||
$postfixEmbedded[$embeddedFilename] = $bytes;
|
||||
$postfixItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
|
||||
'path' => $embeddedFilename,
|
||||
];
|
||||
}
|
||||
$postfixItems = $this->buildExportProItems(
|
||||
ExportProFile::postfix()->orderBy('order')->get(),
|
||||
'POSTFIX',
|
||||
$postfixEmbedded,
|
||||
);
|
||||
|
||||
$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
|
||||
{
|
||||
$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);
|
||||
$zip = new ZipArchive;
|
||||
|
||||
if ($zip->open($storedAbsPath) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$proBytes = null;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
if ($entryName === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_ends_with($entryName, '/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
|
||||
$entryBytes = $zip->getFromIndex($i);
|
||||
if ($entryBytes === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entryExt === 'pro') {
|
||||
$proBytes = $entryBytes;
|
||||
} else {
|
||||
$mediaName = basename($entryName);
|
||||
if (! isset($embedded[$mediaName])) {
|
||||
$embedded[$mediaName] = $entryBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
if ($proBytes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
{
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,24 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
$seenSectionIds = [];
|
||||
$background = $this->backgroundData($service);
|
||||
|
||||
// Pre-compute the total slide count across the whole song (same filtering as the loop below)
|
||||
// so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide.
|
||||
$totalSlidesInSong = 0;
|
||||
$seenForCount = [];
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
if ($section === null || $section->label === null) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($section->id, $seenForCount, true)) {
|
||||
continue;
|
||||
}
|
||||
$seenForCount[] = $section->id;
|
||||
$totalSlidesInSong += $section->slides->count();
|
||||
}
|
||||
|
||||
$globalSlideIndex = 0;
|
||||
|
||||
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||
$section = $arrangementSection->section;
|
||||
$label = $section?->label;
|
||||
|
|
@ -69,9 +87,8 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
|
||||
$slides = [];
|
||||
$sectionSlides = $section->slides->sortBy('order')->values();
|
||||
$totalSlides = $sectionSlides->count();
|
||||
|
||||
foreach ($sectionSlides as $slideIndex => $slide) {
|
||||
foreach ($sectionSlides as $slide) {
|
||||
$slideData = ['text' => $slide->text_content ?? ''];
|
||||
|
||||
if ($slide->text_content_translated) {
|
||||
|
|
@ -86,7 +103,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
$macros = $this->macroResolutionService->macrosForSlide(
|
||||
$service,
|
||||
'song',
|
||||
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
|
||||
['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id],
|
||||
);
|
||||
|
||||
if (! empty($macros)) {
|
||||
|
|
@ -96,6 +113,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
|
|||
}
|
||||
|
||||
$slides[] = $slideData;
|
||||
$globalSlideIndex++;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ async function deleteFile(id) {
|
|||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
Diese .pro-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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -125,13 +125,13 @@ async function deleteFile(id) {
|
|||
<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-Dateien auswählen oder hierher ziehen' }}
|
||||
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".pro"
|
||||
accept=".pro,.probundle"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
data-testid="export-prefix-file-input"
|
||||
|
|
@ -184,13 +184,13 @@ async function deleteFile(id) {
|
|||
<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-Dateien auswählen oder hierher ziehen' }}
|
||||
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".pro"
|
||||
accept=".pro,.probundle"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
data-testid="export-postfix-file-input"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@
|
|||
);
|
||||
Auth::login($user);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
$service = Service::nextUpcoming()->first();
|
||||
|
||||
return $service
|
||||
? redirect()->route('services.edit', $service->id)
|
||||
: redirect()->route('services.index');
|
||||
})->name('dev-login');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
278
tests/Feature/ExportProFileInjectionTest.php
Normal file
278
tests/Feature/ExportProFileInjectionTest.php
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ExportProFile;
|
||||
use App\Models\Label;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\PlaylistExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
final class ExportProFileInjectionTest 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 buildProbundleZip(string $proContent, array $mediaFiles = []): string
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('song.pro', $proContent);
|
||||
|
||||
foreach ($mediaFiles as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $tmpPath;
|
||||
}
|
||||
|
||||
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_probundle_prefix_wird_korrekt_injiziert(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Bundle Test Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent('Test Song');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Test Song',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$proContent = 'dummy-pro-content';
|
||||
$mediaContent = 'dummy-media-bytes';
|
||||
$bundlePath = $this->buildProbundleZip($proContent, ['background.jpg' => $mediaContent]);
|
||||
|
||||
$storedPath = 'export-pro-files/intro.probundle';
|
||||
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
|
||||
unlink($bundlePath);
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'prefix',
|
||||
'original_name' => 'intro.probundle',
|
||||
'stored_path' => $storedPath,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$entries = $playlist->getEntries();
|
||||
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
|
||||
|
||||
$this->assertContains('intro', $entryNames, 'Expected prefix presentation entry named "intro"');
|
||||
$songPos = array_search('Test Song', $entryNames, true);
|
||||
$prefixPos = array_search('intro', $entryNames, true);
|
||||
$this->assertNotFalse($prefixPos, 'Prefix entry not found');
|
||||
$this->assertNotFalse($songPos, 'Song entry not found');
|
||||
$this->assertLessThan($songPos, $prefixPos, 'Prefix should appear before song');
|
||||
|
||||
$embeddedFiles = $playlist->getEmbeddedFiles();
|
||||
$this->assertArrayHasKey('PREFIX_1_intro.pro', $embeddedFiles, 'Inner .pro should be embedded as PREFIX_1_intro.pro');
|
||||
$this->assertSame($proContent, $embeddedFiles['PREFIX_1_intro.pro']);
|
||||
|
||||
$this->assertArrayHasKey('background.jpg', $embeddedFiles, 'Media file from .probundle should be embedded');
|
||||
$this->assertSame($mediaContent, $embeddedFiles['background.jpg']);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_probundle_postfix_wird_nach_songs_injiziert(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Postfix Bundle Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent('Worship Song');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Worship Song',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$bundlePath = $this->buildProbundleZip('outro-pro-content', ['outro-media.jpg' => 'outro-media-bytes']);
|
||||
$storedPath = 'export-pro-files/outro.probundle';
|
||||
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
|
||||
unlink($bundlePath);
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'postfix',
|
||||
'original_name' => 'outro.probundle',
|
||||
'stored_path' => $storedPath,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$entries = $playlist->getEntries();
|
||||
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
|
||||
|
||||
$songPos = array_search('Worship Song', $entryNames, true);
|
||||
$postfixPos = array_search('outro', $entryNames, true);
|
||||
$this->assertNotFalse($postfixPos, 'Postfix entry not found');
|
||||
$this->assertNotFalse($songPos, 'Song entry not found');
|
||||
$this->assertLessThan($postfixPos, $songPos, 'Song should appear before postfix');
|
||||
|
||||
$embeddedFiles = $playlist->getEmbeddedFiles();
|
||||
$this->assertArrayHasKey('POSTFIX_1_outro.pro', $embeddedFiles);
|
||||
$this->assertSame('outro-pro-content', $embeddedFiles['POSTFIX_1_outro.pro']);
|
||||
$this->assertArrayHasKey('outro-media.jpg', $embeddedFiles);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Skip Bundle Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent('Einziger Song');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Einziger Song',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('media.jpg', 'media-only');
|
||||
$zip->close();
|
||||
|
||||
$storedPath = 'export-pro-files/broken.probundle';
|
||||
Storage::disk('local')->put($storedPath, file_get_contents($tmpPath));
|
||||
unlink($tmpPath);
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'prefix',
|
||||
'original_name' => 'broken.probundle',
|
||||
'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('broken', $entryNames);
|
||||
$this->assertContains('Einziger Song', $entryNames);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_plain_pro_prefix_weiterhin_korrekt(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Plain Pro Service',
|
||||
'date' => now(),
|
||||
]);
|
||||
|
||||
$song = $this->createSongWithContent('Lied');
|
||||
ServiceSong::create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => $song->id,
|
||||
'cts_song_name' => 'Lied',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$storedPath = 'export-pro-files/welcome.pro';
|
||||
Storage::disk('local')->put($storedPath, 'welcome-pro-bytes');
|
||||
|
||||
ExportProFile::create([
|
||||
'type' => 'prefix',
|
||||
'original_name' => 'welcome.pro',
|
||||
'stored_path' => $storedPath,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
|
||||
$entries = $playlist->getEntries();
|
||||
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
|
||||
|
||||
$this->assertContains('welcome', $entryNames);
|
||||
$prefixPos = array_search('welcome', $entryNames, true);
|
||||
$songPos = array_search('Lied', $entryNames, true);
|
||||
$this->assertLessThan($songPos, $prefixPos, 'Plain .pro prefix should appear before song');
|
||||
|
||||
$embeddedFiles = $playlist->getEmbeddedFiles();
|
||||
$this->assertArrayHasKey('PREFIX_1_welcome.pro', $embeddedFiles);
|
||||
$this->assertSame('welcome-pro-bytes', $embeddedFiles['PREFIX_1_welcome.pro']);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
}
|
||||
102
tests/Feature/ExportProFileUploadTest.php
Normal file
102
tests/Feature/ExportProFileUploadTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ExportProFileUploadTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
public function test_pro_datei_wird_als_prefix_akzeptiert(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('intro.pro', 10, 'application/octet-stream');
|
||||
|
||||
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
|
||||
'type' => 'prefix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('files.0.original_name', 'intro.pro');
|
||||
}
|
||||
|
||||
public function test_probundle_datei_wird_als_prefix_akzeptiert(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('intro.probundle', 10, 'application/octet-stream');
|
||||
|
||||
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
|
||||
'type' => 'prefix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('files.0.original_name', 'intro.probundle');
|
||||
}
|
||||
|
||||
public function test_pro_datei_wird_als_postfix_akzeptiert(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('outro.pro', 10, 'application/octet-stream');
|
||||
|
||||
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
|
||||
'type' => 'postfix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('files.0.original_name', 'outro.pro');
|
||||
}
|
||||
|
||||
public function test_probundle_datei_wird_als_postfix_akzeptiert(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('outro.probundle', 10, 'application/octet-stream');
|
||||
|
||||
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
|
||||
'type' => 'postfix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('files.0.original_name', 'outro.probundle');
|
||||
}
|
||||
|
||||
public function test_unerlaubte_dateiendung_wird_abgelehnt(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
|
||||
'type' => 'prefix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_unerlaubte_dateiendung_postfix_wird_abgelehnt(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
|
||||
'type' => 'postfix',
|
||||
'files' => [$file],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,24 +47,22 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi
|
|||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||
|
||||
// Content-less item before any real content → HEADLINE (not keyvisual fallback)
|
||||
$this->assertCount(1, $embeddedProFiles);
|
||||
// Content-less item before any real content → HEADER playlist item (no embedded .pro)
|
||||
$this->assertCount(0, $playlist->getEmbeddedProFiles());
|
||||
$this->assertSame($slideCountBefore, Slide::count());
|
||||
|
||||
// Headline filename matches pattern: Begrüßung-headline-<uniqid>.pro
|
||||
$headlineKey = null;
|
||||
foreach (array_keys($embeddedProFiles) as $key) {
|
||||
if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) {
|
||||
$headlineKey = $key;
|
||||
// Playlist has a header entry named 'Begrüßung'
|
||||
$headerEntry = null;
|
||||
foreach ($playlist->getEntries() as $entry) {
|
||||
if ($entry->isHeader() && $entry->getName() === 'Begrüßung') {
|
||||
$headerEntry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung');
|
||||
$this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey);
|
||||
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung');
|
||||
|
||||
// No KEY_VISUAL.jpg embedded (headline has no background media)
|
||||
// No KEY_VISUAL.jpg embedded (header has no background media)
|
||||
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
|
|
@ -189,20 +187,20 @@ public function test_empty_non_song_item_before_content_becomes_headline(): void
|
|||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||
|
||||
// Content-less item before real content → HEADLINE (adds a playlist entry)
|
||||
// So we expect 2 embedded pro files: song + headline
|
||||
$this->assertCount(2, $embeddedProFiles);
|
||||
// Content-less item before real content → HEADER playlist item (no embedded .pro for headline)
|
||||
// Only the song .pro is embedded
|
||||
$this->assertCount(1, $embeddedProFiles);
|
||||
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
|
||||
|
||||
// Headline filename matches pattern: Begrüßung ohne Folien-headline-<uniqid>.pro
|
||||
$headlineKey = null;
|
||||
foreach (array_keys($embeddedProFiles) as $key) {
|
||||
if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) {
|
||||
$headlineKey = $key;
|
||||
// Playlist has a header entry named 'Begrüßung ohne Folien'
|
||||
$headerEntry = null;
|
||||
foreach ($playlist->getEntries() as $entry) {
|
||||
if ($entry->isHeader() && $entry->getName() === 'Begrüßung ohne Folien') {
|
||||
$headerEntry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien');
|
||||
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung ohne Folien');
|
||||
|
||||
// No KEY_VISUAL.jpg embedded (no keyvisual configured)
|
||||
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
|
|
|||
|
|
@ -139,21 +139,31 @@
|
|||
expect($name)->toBeNull();
|
||||
});
|
||||
|
||||
test('worship leader resolves from lobpreis agenda item responsibles', function () {
|
||||
test('worship leader resolves from lobpreis role in responsible persons', function () {
|
||||
$service = Service::factory()->create();
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'responsible' => [['name' => 'Moderator Max']],
|
||||
'responsible' => [
|
||||
'text' => '[Moderation]',
|
||||
'persons' => [
|
||||
['service' => '[Moderation]', 'accepted' => true, 'person' => ['title' => 'Moderator Max']],
|
||||
],
|
||||
],
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Lobpreis',
|
||||
'is_before_event' => false,
|
||||
'responsible' => [['name' => 'Lea Leiter']],
|
||||
'responsible' => [
|
||||
'text' => '[Lobpreis]',
|
||||
'persons' => [
|
||||
['service' => '[Lobpreis]', 'accepted' => true, 'person' => ['title' => 'Lea Leiter']],
|
||||
],
|
||||
],
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
|
|
@ -177,3 +187,124 @@
|
|||
|
||||
expect($name)->toBeNull();
|
||||
});
|
||||
|
||||
test('name extracted from real cts persons shape via person.title', function () {
|
||||
$service = Service::factory()->create(['moderator_name' => null]);
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'sort_order' => 1,
|
||||
'responsible' => [
|
||||
'text' => '[Moderation]',
|
||||
'persons' => [
|
||||
[
|
||||
'service' => '[Moderation]',
|
||||
'accepted' => true,
|
||||
'person' => [
|
||||
'title' => 'Kornelius Weiß',
|
||||
'domainAttributes' => ['firstName' => 'Kornelius', 'lastName' => 'Weiß'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->moderatorFor($service);
|
||||
|
||||
expect($name)->toBe('Kornelius Weiß');
|
||||
});
|
||||
|
||||
test('name falls back to domainAttributes when person.title is absent', function () {
|
||||
$service = Service::factory()->create(['moderator_name' => null]);
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'sort_order' => 1,
|
||||
'responsible' => [
|
||||
'text' => '[Moderation]',
|
||||
'persons' => [
|
||||
[
|
||||
'service' => '[Moderation]',
|
||||
'accepted' => true,
|
||||
'person' => [
|
||||
'domainAttributes' => ['firstName' => 'Anna', 'lastName' => 'Müller'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->moderatorFor($service);
|
||||
|
||||
expect($name)->toBe('Anna Müller');
|
||||
});
|
||||
|
||||
test('worship leader resolves from persons service role lobpreis in real cts shape', function () {
|
||||
$service = Service::factory()->create();
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'sort_order' => 1,
|
||||
'responsible' => [
|
||||
'text' => '[Moderation]',
|
||||
'persons' => [
|
||||
[
|
||||
'service' => '[Moderation]',
|
||||
'accepted' => true,
|
||||
'person' => ['title' => 'Moderator Max'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Lobpreis',
|
||||
'is_before_event' => false,
|
||||
'sort_order' => 2,
|
||||
'responsible' => [
|
||||
'text' => '[Lobpreis]',
|
||||
'persons' => [
|
||||
[
|
||||
'service' => '[Lobpreis]',
|
||||
'accepted' => true,
|
||||
'person' => ['title' => 'Kornelius Weiß'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
|
||||
|
||||
expect($name)->toBe('Kornelius Weiß');
|
||||
});
|
||||
|
||||
test('worship leader returns null when no person has lobpreis role in real cts shape', function () {
|
||||
$service = Service::factory()->create();
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Lobpreis',
|
||||
'is_before_event' => false,
|
||||
'sort_order' => 1,
|
||||
'responsible' => [
|
||||
'text' => '[Moderation]',
|
||||
'persons' => [
|
||||
[
|
||||
'service' => '[Moderation]',
|
||||
'accepted' => true,
|
||||
'person' => ['title' => 'Moderator Max'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
|
||||
|
||||
expect($name)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\Setting;
|
||||
use App\Services\NameTagSlideBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('build returns null when namenseinblender macro is not configured', function () {
|
||||
expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull();
|
||||
|
|
@ -37,3 +41,26 @@
|
|||
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
|
||||
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt");
|
||||
});
|
||||
|
||||
test('build returns slide with macro when macro_id points to existing macro', function () {
|
||||
$macro = Macro::factory()->create([
|
||||
'name' => 'Namenseinblender',
|
||||
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
]);
|
||||
Setting::set('namenseinblender_macro_id', (string) $macro->id);
|
||||
|
||||
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
||||
|
||||
expect($slide)->toHaveKey('text', "Lea Leiter\nModeration")
|
||||
->and($slide)->toHaveKey('macro')
|
||||
->and($slide['macro']['name'])->toBe('Namenseinblender')
|
||||
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
||||
});
|
||||
|
||||
test('build returns slide without macro when macro_id points to missing macro', function () {
|
||||
Setting::set('namenseinblender_macro_id', '99999');
|
||||
|
||||
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
||||
|
||||
expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
|
@ -50,7 +51,9 @@
|
|||
});
|
||||
|
||||
it('creates a new user from OAuth callback', function () {
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$service = Service::factory()->create(['date' => now()->addDays(7)]);
|
||||
|
||||
$socialiteUser = new SocialiteUser();
|
||||
$socialiteUser->map([
|
||||
'id' => '42',
|
||||
'name' => 'Max Mustermann',
|
||||
|
|
@ -80,7 +83,7 @@
|
|||
|
||||
$response = $this->get('/auth/churchtools/callback');
|
||||
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
$response->assertRedirect(route('services.edit', $service->id));
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'max@example.com',
|
||||
|
|
@ -98,13 +101,15 @@
|
|||
});
|
||||
|
||||
it('updates existing user on OAuth callback', function () {
|
||||
$service = Service::factory()->create(['date' => now()->addDays(7)]);
|
||||
|
||||
$existingUser = User::factory()->create([
|
||||
'email' => 'max@example.com',
|
||||
'name' => 'Old Name',
|
||||
'churchtools_id' => 42,
|
||||
]);
|
||||
|
||||
$socialiteUser = new SocialiteUser;
|
||||
$socialiteUser = new SocialiteUser();
|
||||
$socialiteUser->map([
|
||||
'id' => '42',
|
||||
'name' => 'Max Mustermann',
|
||||
|
|
@ -134,7 +139,7 @@
|
|||
|
||||
$response = $this->get('/auth/churchtools/callback');
|
||||
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
$response->assertRedirect(route('services.edit', $service->id));
|
||||
|
||||
$existingUser->refresh();
|
||||
expect($existingUser->name)->toBe('Max Mustermann');
|
||||
|
|
|
|||
|
|
@ -298,6 +298,59 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
|
|||
}
|
||||
}
|
||||
|
||||
public function test_export_first_slide_macro_nur_auf_erster_folie_des_ganzen_songs(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total
|
||||
$song = $this->createSongWithContent();
|
||||
$macro = $this->createMacroForExport('Erste Folie Macro');
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'first_slide',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
$slides = $this->allParserSlides($parserSong);
|
||||
|
||||
// Exactly 3 slides: Verse-0, Verse-1, Chorus-0
|
||||
$this->assertCount(3, $slides);
|
||||
|
||||
// Only the very first slide of the whole song gets the macro
|
||||
$this->assertTrue($slides[0]->hasMacro(), 'Erste Folie (global index 0) muss Macro haben');
|
||||
$this->assertSame('Erste Folie Macro', $slides[0]->getMacroName());
|
||||
|
||||
// Chorus slide 0 is the first slide of its section but NOT the first of the song → no macro
|
||||
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein first_slide-Macro haben');
|
||||
$this->assertFalse($slides[2]->hasMacro(), 'Chorus-Folie 0 darf kein first_slide-Macro haben (nicht erste Folie des Songs)');
|
||||
}
|
||||
|
||||
public function test_export_last_slide_macro_nur_auf_letzter_folie_des_ganzen_songs(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total; last = Chorus-0
|
||||
$song = $this->createSongWithContent();
|
||||
$macro = $this->createMacroForExport('Letzte Folie Macro');
|
||||
MacroAssignment::create([
|
||||
'part_type' => 'song',
|
||||
'macro_id' => $macro->id,
|
||||
'position' => 'last_slide',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
||||
$slides = $this->allParserSlides($parserSong);
|
||||
|
||||
$this->assertCount(3, $slides);
|
||||
|
||||
// Only the very last slide of the whole song gets the macro
|
||||
$this->assertFalse($slides[0]->hasMacro(), 'Verse-Folie 0 darf kein last_slide-Macro haben');
|
||||
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein last_slide-Macro haben (letzte Folie der Sektion, aber nicht des Songs)');
|
||||
$this->assertTrue($slides[2]->hasMacro(), 'Letzte Folie des Songs (global index 2) muss Macro haben');
|
||||
$this->assertSame('Letzte Folie Macro', $slides[2]->getMacroName());
|
||||
}
|
||||
|
||||
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
|
|
|||
Loading…
Reference in a new issue