pp-planer/app/Services/NameTagResolver.php
Thorsten Bus e33418f716 feat: song pre/postfix, settings overhaul, export & schedule fixes
Resolves a batch of bugs and feature requests across songs, services,
settings and export:

Songs & sections
- Every song now carries permanent, empty, locked PREFIX (COPYRIGHT) and
  POSTFIX (BLANK) sections, deduplicated on import; locked sections cannot
  be edited or deleted via UI or API.
- Song edit modal: explicit Speichern/Schließen with dirty-tracking,
  editable section headline (combobox + custom values), and a fix for the
  419 CSRF errors after CCLI "Importieren & Bearbeiten" (token read fresh
  per request).
- CCLI bookmarklet "Importieren & Bearbeiten" now opens the edit dialog.

Service schedule & arrangements
- Fixed assigned songs showing no sections (slides loaded for all
  arrangements, not just the default).
- Added "Song entfernen / neu zuordnen" to reassign an assigned song.
- Worship-leader arrangement is created/selected lazily when the
  arrangement dialog opens (only when not user-overridden); the leader is
  resolved from the "Lobpreis" agenda item, and manual create/clone names
  are prefixed with the leader name.

Navigation
- "/" redirects to the next upcoming service's edit page (or the list).
- Service titles link to the edit page.

Settings
- Renamed "Makro-Import"/"Label-Import" menu items; fixed drag-and-drop
  imports (were downloading the dropped file); added label-import hint;
  made the panel scrollable.
- Nametag now uses a single MacroPicker; added song prefix/postfix label
  defaults (COPYRIGHT #24B34C / BLANK #000000); new "Export-Dateien" menu
  to upload prefix/postfix .pro files added to every export.

Export
- Filenames/playlist names are date-first ("YYYY-MM-DD <Title>").
- Keyvisual slide only for the first content-less item after real content;
  all other content-less items render as headlines.
- New "Vorschau herunterladen" for non-finalized services (filename and
  import name prefixed "Vorschau" with export timestamp).
- Uploaded prefix/postfix .pro files wrap every export.

Tests updated to the new behavior; full suite green (569 passed).
2026-06-01 08:56:20 +02:00

152 lines
4.5 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;
}
$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
{
$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;
}
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;
}
$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;
}
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;
}
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
$names = collect($people)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
->filter()
->values()
->all();
return $names === [] ? null : implode(', ', $names);
}
private function nameFromResponsiblePerson(mixed $person): ?string
{
if (is_string($person)) {
return $this->filledString($person);
}
if (! is_array($person)) {
return null;
}
$name = $this->filledString($person['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) ?? '';
$fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName;
}
private function isWorshipItem(ServiceAgendaItem $item): bool
{
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
return str_contains($title, 'lobpreis')
|| str_contains($type, 'lobpreis');
}
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 !== '',
));
}
}