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

224 lines
6.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\Song as ProSong;
use ZipArchive;
class ProImportService
{
public function __construct(
private readonly SongPrefixPostfixService $songPrefixPostfixService,
) {}
/** @return Song[] */
public function import(UploadedFile $file): array
{
$extension = strtolower($file->getClientOriginalExtension());
if ($extension === 'zip') {
return $this->importZip($file);
}
if ($extension === 'pro') {
return [$this->importProFile($file->getRealPath())];
}
throw new \InvalidArgumentException('Nur .pro und .zip Dateien sind erlaubt.');
}
/** @return Song[] */
private function importZip(UploadedFile $file): array
{
$zip = new ZipArchive;
if ($zip->open($file->getRealPath()) !== true) {
throw new \RuntimeException('ZIP-Datei konnte nicht geöffnet werden.');
}
$tempDir = sys_get_temp_dir().'/pro-import-'.uniqid();
mkdir($tempDir, 0755, true);
$songs = [];
try {
$zip->extractTo($tempDir);
$zip->close();
$proFiles = glob($tempDir.'/*.pro') ?: [];
$proFilesNested = glob($tempDir.'/**/*.pro') ?: [];
$allProFiles = array_unique(array_merge($proFiles, $proFilesNested));
if (empty($allProFiles)) {
throw new \RuntimeException('Keine .pro Dateien im ZIP-Archiv gefunden.');
}
foreach ($allProFiles as $proPath) {
$songs[] = $this->importProFile($proPath);
}
} finally {
$this->deleteDirectory($tempDir);
}
return $songs;
}
private function importProFile(string $filePath): Song
{
$proSong = ProFileReader::read($filePath);
return DB::transaction(function () use ($proSong) {
return $this->upsertSong($proSong);
});
}
private function upsertSong(ProSong $proSong): Song
{
$ccliId = $proSong->getCcliSongNumber();
$songData = [
'title' => $proSong->getName(),
'author' => $proSong->getCcliAuthor() ?: null,
'copyright_text' => $proSong->getCcliPublisher() ?: null,
'copyright_year' => $proSong->getCcliCopyrightYear() ?: null,
'publisher' => $proSong->getCcliPublisher() ?: null,
];
if ($ccliId) {
$song = Song::withTrashed()->where('ccli_id', (string) $ccliId)->first();
if ($song) {
if ($song->trashed()) {
$song->restore();
}
$song->update($songData);
} else {
$song = Song::create(array_merge($songData, ['ccli_id' => (string) $ccliId]));
}
} else {
$song = Song::create(array_merge($songData, ['ccli_id' => null]));
}
$song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementSections()->delete();
});
$song->arrangements()->delete();
$hasTranslation = false;
$sectionsByName = [];
foreach ($proSong->getGroups() as $groupOrder => $proGroup) {
$groupName = $proGroup->getName();
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
if ($existingLabel === null) {
$color = $proGroup->getColor();
$hexColor = MacroColorConverter::fromRgba($color);
$existingLabel = Label::create([
'name' => $groupName,
'color' => $hexColor,
]);
}
$section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $existingLabel->id],
['order' => $groupOrder + 1],
);
$section->update(['order' => $groupOrder + 1]);
$sectionsByName[$groupName] = $section;
$section->slides()->delete();
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null;
if ($proSlide->hasTranslation()) {
$translatedText = $proSlide->getTranslation()->getPlainText();
$hasTranslation = true;
}
$section->slides()->create([
'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText,
]);
}
}
$song->update(['has_translation' => $hasTranslation]);
foreach ($proSong->getArrangements() as $proArrangement) {
$arrangement = $song->arrangements()->create([
'name' => $proArrangement->getName(),
'is_default' => strtolower($proArrangement->getName()) === 'normal',
]);
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) {
$section = $sectionsByName[$proGroup->getName()] ?? null;
if ($section) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => $order,
]);
}
}
}
$this->songPrefixPostfixService->ensure($song);
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
}
public static function rgbaToHex(array $rgba): string
{
$r = (int) round(($rgba['r'] ?? 0) * 255);
$g = (int) round(($rgba['g'] ?? 0) * 255);
$b = (int) round(($rgba['b'] ?? 0) * 255);
return sprintf('#%02X%02X%02X', $r, $g, $b);
}
public static function hexToRgba(string $hex): array
{
$hex = ltrim($hex, '#');
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
return [round($r, 4), round($g, 4), round($b, 4), 1.0];
}
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
}