pp-planer/app/Services/NameTagResolver.php
Thorsten Bus d5f3990f3b 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).
2026-06-01 22:17:31 +02:00

246 lines
7.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NameTagResolver
{
public function __construct(
private readonly AgendaMatcherService $agendaMatcherService,
) {}
public function moderatorFor(Service $service): ?string
{
$override = $this->filledString($service->moderator_name);
if ($override !== null) {
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')
->orderBy('id')
->first();
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
}
public function worshipLeaderFor(Service $service): ?string
{
return $this->nameForRole($service, 'lobpreis');
}
public function preacherFor(Service $service): ?string
{
$override = $this->filledString($service->preacher_name_override);
if ($override !== null) {
return $override;
}
$preacherName = $this->filledString($service->preacher_name);
if ($preacherName !== null) {
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')
->orderBy('sort_order')
->orderBy('id')
->get()
->first(fn (ServiceAgendaItem $item) => $this->isSermonItem($item));
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);
return $trimmed === '' ? null : $trimmed;
}
private function namesFromResponsible(mixed $responsible): ?string
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
// 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($entries)
->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry))
->filter()
->values()
->all();
return $names === [] ? null : implode(', ', $names);
}
/**
* 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($entry)) {
return $this->filledString($entry);
}
if (! is_array($entry)) {
return 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;
}
$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;
}
/**
* 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
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
// 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
{
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));
if ($configuredPatterns !== []) {
return $this->agendaMatcherService->matchesAny($item->title, $configuredPatterns);
}
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
return str_contains($title, 'predigt')
|| str_contains($title, 'sermon')
|| str_contains($type, 'predigt')
|| str_contains($type, 'sermon');
}
/** @return array<int, string> */
private function patternsFromSetting(?string $patterns): array
{
if ($patterns === null || trim($patterns) === '') {
return [];
}
return array_values(array_filter(
array_map(fn (string $pattern) => trim($pattern), explode(',', $patterns)),
fn (string $pattern) => $pattern !== '',
));
}
}