Refactor lyric storage so each song owns its sections instead of sharing global labels. Adds song_sections (per song+label) owning song_slides; labels stay global ProPresenter group tags (name/color/macro). Arrangements now reference sections, so editing/importing one song no longer corrupts others that share a label name. - New: song_sections table + migration with safe backfill; SongSection, SongArrangementSection models; SongSectionController (edit/add/delete sections, immediate persistence) wired into SongEditModal. - Refactor writers/readers: CcliImport, ProImport, SongService, ArrangementController, SongController, ProExport, PDF, Translation (translation reset now section-scoped), CCLI pairing. - CCLI import fixes: parse SongSelect copy-icon format (German "Vers" abbrev + trailing author), fill empty CTS-synced songs instead of blocking as duplicate, distinct label colors per section kind, import&edit/existing-song open the edit modal (no 404/405), teleport paste dialog above assign dialog, preview shows section content, correct SongSelect search URL, copy-icon instructions. - Bookmarklet clicks #generalCopyLyricsButton and captures clipboard; serves correct host from request. - Export: embed key-visual/background under fixed bundle-relative names. - Tests updated for the section model; new section + isolation coverage.
258 lines
8.2 KiB
PHP
258 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Service;
|
|
use App\Models\ServiceAgendaItem;
|
|
use App\Models\Setting;
|
|
use App\Models\Slide;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use InvalidArgumentException;
|
|
use ProPresenter\Parser\PresentationBundle;
|
|
use ProPresenter\Parser\ProBundleWriter;
|
|
use ProPresenter\Parser\ProFileGenerator;
|
|
use RuntimeException;
|
|
|
|
class ProBundleExportService
|
|
{
|
|
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
|
|
|
|
public function __construct(
|
|
private readonly MacroResolutionService $macroResolutionService,
|
|
private readonly ServiceImageResolver $imageResolver,
|
|
) {}
|
|
|
|
public function generateBundle(Service $service, string $blockType): string
|
|
{
|
|
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
|
|
throw new InvalidArgumentException('Ungültiger Blocktyp für .probundle Export.');
|
|
}
|
|
|
|
$slides = $service->slides()
|
|
->where('type', $blockType)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
$groupName = ucfirst($blockType);
|
|
|
|
return $this->buildBundleFromSlides($slides, $groupName, $service, $blockType, $blockType);
|
|
}
|
|
|
|
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
|
{
|
|
$agendaItem->loadMissing([
|
|
'service',
|
|
'slides',
|
|
'serviceSong.song.arrangements.arrangementSections.section.slides',
|
|
'serviceSong.song.arrangements.arrangementSections.section.label',
|
|
]);
|
|
|
|
$title = $agendaItem->title ?: 'Ablauf-Element';
|
|
|
|
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
|
$song = $agendaItem->serviceSong->song;
|
|
|
|
$labelCount = $song->arrangements()
|
|
->withCount('arrangementLabels')
|
|
->get()
|
|
->sum('arrangement_labels_count');
|
|
|
|
if ($labelCount === 0) {
|
|
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
|
|
}
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
|
$proFilename = self::safeFilename($song->title).'.pro';
|
|
|
|
$songMediaFiles = [];
|
|
$this->embedBackground($agendaItem->service, $songMediaFiles);
|
|
|
|
$bundle = new PresentationBundle($parserSong, $proFilename, $songMediaFiles);
|
|
$bundlePath = sys_get_temp_dir().'/'.uniqid('agenda-song-').'.probundle';
|
|
ProBundleWriter::write($bundle, $bundlePath);
|
|
|
|
return $bundlePath;
|
|
}
|
|
|
|
$slides = $agendaItem->slides()
|
|
->whereNull('deleted_at')
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
return $this->buildBundleFromSlides(
|
|
$slides,
|
|
$title,
|
|
$agendaItem->service,
|
|
'agenda_item',
|
|
$this->backgroundPartTypeForAgendaItem($agendaItem),
|
|
);
|
|
}
|
|
|
|
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
|
|
private function buildBundleFromSlides(
|
|
$slides,
|
|
string $groupName,
|
|
?Service $service = null,
|
|
?string $partType = null,
|
|
?string $backgroundPartType = null,
|
|
): string {
|
|
$slideData = [];
|
|
$mediaFiles = [];
|
|
$background = $this->backgroundData($service);
|
|
$backgroundAttached = false;
|
|
|
|
foreach ($slides as $slide) {
|
|
$sourcePath = Storage::disk('public')->path($slide->stored_filename);
|
|
if (! file_exists($sourcePath)) {
|
|
continue;
|
|
}
|
|
|
|
$imageFilename = basename($slide->stored_filename);
|
|
$imageContent = file_get_contents($sourcePath);
|
|
if ($imageContent === false) {
|
|
continue;
|
|
}
|
|
|
|
$mediaFiles[$imageFilename] = $imageContent;
|
|
|
|
$singleSlideData = [
|
|
'media' => $imageFilename,
|
|
'format' => 'JPG',
|
|
'label' => $slide->original_filename,
|
|
];
|
|
|
|
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
|
$singleSlideData['background'] = $background;
|
|
$backgroundAttached = true;
|
|
}
|
|
|
|
if ($service !== null && $partType !== null) {
|
|
$slideIndex = count($slideData);
|
|
$totalSlides = $slides->count();
|
|
$macros = $this->macroResolutionService->macrosForSlide(
|
|
$service,
|
|
$partType,
|
|
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => null],
|
|
);
|
|
|
|
if (! empty($macros)) {
|
|
// ProPresenter parser currently supports one `macro` entry per slide
|
|
$singleSlideData['macro'] = $macros[0];
|
|
}
|
|
}
|
|
|
|
$slideData[] = $singleSlideData;
|
|
}
|
|
|
|
if ($backgroundAttached) {
|
|
$this->embedBackground($service, $mediaFiles);
|
|
}
|
|
|
|
$groups = [
|
|
[
|
|
'name' => $groupName,
|
|
'color' => [0, 0, 0, 1],
|
|
'slides' => $slideData,
|
|
],
|
|
];
|
|
|
|
$arrangements = [
|
|
[
|
|
'name' => 'normal',
|
|
'groupNames' => [$groupName],
|
|
],
|
|
];
|
|
|
|
$song = ProFileGenerator::generate($groupName, $groups, $arrangements);
|
|
$proFilename = self::safeFilename($groupName).'.pro';
|
|
|
|
$bundle = new PresentationBundle($song, $proFilename, $mediaFiles);
|
|
$bundlePath = sys_get_temp_dir().'/'.uniqid('bundle-').'.probundle';
|
|
ProBundleWriter::write($bundle, $bundlePath);
|
|
|
|
return $bundlePath;
|
|
}
|
|
|
|
private function backgroundData(?Service $service): ?array
|
|
{
|
|
if ($this->backgroundSourcePath($service) === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
|
|
'format' => 'JPG',
|
|
'width' => 1920,
|
|
'height' => 1080,
|
|
'bundleRelative' => true,
|
|
];
|
|
}
|
|
|
|
/** Absolute filesystem path of the resolved background image, or null. */
|
|
private function backgroundSourcePath(?Service $service): ?string
|
|
{
|
|
if ($service === null) {
|
|
return null;
|
|
}
|
|
|
|
$background = $this->imageResolver->backgroundFor($service);
|
|
|
|
if ($background === null || ! Storage::disk('public')->exists($background)) {
|
|
return null;
|
|
}
|
|
|
|
return Storage::disk('public')->path($background);
|
|
}
|
|
|
|
/** Embed the resolved background image bytes into the bundle under the fixed export name. */
|
|
private function embedBackground(?Service $service, array &$mediaFiles): void
|
|
{
|
|
$sourcePath = $this->backgroundSourcePath($service);
|
|
|
|
if ($sourcePath === null || isset($mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
|
|
return;
|
|
}
|
|
|
|
$contents = @file_get_contents($sourcePath);
|
|
if ($contents !== false) {
|
|
$mediaFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
|
|
}
|
|
}
|
|
|
|
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
|
{
|
|
if ($background === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
|
|
return false;
|
|
}
|
|
|
|
return $slide->cover_mode !== true;
|
|
}
|
|
|
|
private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $agendaItem): string
|
|
{
|
|
if ($agendaItem->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
|
|
return 'sermon';
|
|
}
|
|
|
|
$sermonPatterns = Setting::get('agenda_sermon_matching');
|
|
if ($sermonPatterns === null) {
|
|
return 'agenda_item';
|
|
}
|
|
|
|
$patterns = array_map('trim', explode(',', $sermonPatterns));
|
|
|
|
return app(AgendaMatcherService::class)->matchesAny($agendaItem->title, $patterns)
|
|
? 'sermon'
|
|
: 'agenda_item';
|
|
}
|
|
|
|
private static function safeFilename(string $name): string
|
|
{
|
|
return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
|
|
}
|
|
}
|