pp-planer/app/Services/SongPrefixPostfixService.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

134 lines
4.1 KiB
PHP

<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Setting;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use Illuminate\Support\Facades\DB;
class SongPrefixPostfixService
{
public function ensure(Song $song): void
{
DB::transaction(function () use ($song): void {
$prefixLabelId = $this->resolvePrefixLabelId();
$postfixLabelId = $this->resolvePostfixLabelId();
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0);
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX);
$arrangement = $this->resolveDefaultArrangement($song);
$this->ensureSectionInArrangement($arrangement, $prefixSection, 'first');
$this->ensureSectionInArrangement($arrangement, $postfixSection, 'last');
});
}
private function resolvePrefixLabelId(): int
{
$id = Setting::get('song_prefix_label_id');
if ($id !== null && $id !== '') {
return (int) $id;
}
$label = Label::firstOrCreate(
['name' => 'COPYRIGHT'],
['color' => '#24B34C'],
);
Setting::set('song_prefix_label_id', (string) $label->id);
return $label->id;
}
private function resolvePostfixLabelId(): int
{
$id = Setting::get('song_postfix_label_id');
if ($id !== null && $id !== '') {
return (int) $id;
}
$label = Label::firstOrCreate(
['name' => 'BLANK'],
['color' => '#000000'],
);
Setting::set('song_postfix_label_id', (string) $label->id);
return $label->id;
}
private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection
{
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $labelId],
['order' => $orderHint, 'locked' => true],
);
$section->update(['locked' => true]);
$section->slides()->delete();
return $section;
}
private function resolveDefaultArrangement(Song $song): SongArrangement
{
return SongArrangement::firstOrCreate(
['song_id' => $song->id, 'name' => 'normal'],
['is_default' => true],
);
}
private function ensureSectionInArrangement(
SongArrangement $arrangement,
SongSection $section,
string $position,
): void {
$existing = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('song_section_id', $section->id)
->first();
if ($position === 'first') {
if ($existing === null) {
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->increment('order');
SongArrangementSection::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 0,
]);
} else {
if ($existing->order !== 0) {
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('id', '!=', $existing->id)
->where('order', '<', $existing->order)
->increment('order');
$existing->update(['order' => 0]);
}
}
} else {
$maxOrder = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
->where('song_section_id', '!=', $section->id)
->max('order') ?? 0;
if ($existing === null) {
SongArrangementSection::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => $maxOrder + 1,
]);
} else {
$existing->update(['order' => $maxOrder + 1]);
}
}
}
}