fix(export): copyright/blank slides, bundle-relative media, probundle injection, song links
- populate COPYRIGHT (title/author/copyright/CCLI) + blank slides on every song; songHasContent ignores locked sections - foreground info/moderation images now bundle-relative (fixes blank images) - pre-added .probundle injection: Zip64-fix + verbatim .pro extraction (fixes empty bundle) - nametag subtitle split (text + subtitle); smaller non-bold render - skip songs with no content slides at export with German warning - link service agenda songs to SongDB edit modal via #song-<id> - allow CCLI import of metadata-only songs (no lyric sections) - expose has_content_slides on service songs; show "Keine Inhaltsfolien"
This commit is contained in:
parent
6cd3dbbc4f
commit
42b8b5f428
|
|
@ -134,6 +134,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'serviceSongs.song.sections.label',
|
'serviceSongs.song.sections.label',
|
||||||
'serviceSongs.song.sections.slides',
|
'serviceSongs.song.sections.slides',
|
||||||
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
||||||
|
'serviceSongs.song.arrangements.arrangementSections.section.slides',
|
||||||
'serviceSongs.arrangement',
|
'serviceSongs.arrangement',
|
||||||
'slides',
|
'slides',
|
||||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||||
|
|
@ -308,6 +309,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
||||||
'song_arrangement_id' => $ss->song_arrangement_id,
|
'song_arrangement_id' => $ss->song_arrangement_id,
|
||||||
'matched_at' => $ss->matched_at?->toJSON(),
|
'matched_at' => $ss->matched_at?->toJSON(),
|
||||||
'request_sent_at' => $ss->request_sent_at?->toJSON(),
|
'request_sent_at' => $ss->request_sent_at?->toJSON(),
|
||||||
|
'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null,
|
||||||
'song' => $ss->song ? [
|
'song' => $ss->song ? [
|
||||||
'id' => $ss->song->id,
|
'id' => $ss->song->id,
|
||||||
'title' => $ss->song->title,
|
'title' => $ss->song->title,
|
||||||
|
|
@ -537,4 +539,30 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
||||||
])
|
])
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft, ob das Standard-Arrangement des Songs mindestens eine Inhaltsfolie hat.
|
||||||
|
* Inhaltsfolie = SongSlide in einer NICHT gesperrten Section (locked null = nicht gesperrt),
|
||||||
|
* die Teil des Standard-Arrangements ist. Relationen sind bereits eager-geladen.
|
||||||
|
*/
|
||||||
|
private function defaultArrangementHasContentSlides(Song $song): bool
|
||||||
|
{
|
||||||
|
$defaultArrangement = $song->arrangements
|
||||||
|
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($defaultArrangement === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $defaultArrangement->arrangementSections->contains(function ($arrangementSection): bool {
|
||||||
|
$section = $arrangementSection->section;
|
||||||
|
|
||||||
|
if ($section === null || (bool) ($section->locked ?? false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $section->slides->isNotEmpty();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Http\Requests\SongRequest;
|
use App\Http\Requests\SongRequest;
|
||||||
use App\Models\Label;
|
use App\Models\Label;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
|
use App\Services\SongPrefixPostfixService;
|
||||||
use App\Services\SongService;
|
use App\Services\SongService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -14,6 +15,7 @@ class SongController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SongService $songService,
|
private readonly SongService $songService,
|
||||||
|
private readonly SongPrefixPostfixService $songPrefixPostfixService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
|
|
@ -98,6 +100,8 @@ public function update(SongRequest $request, int $id): JsonResponse
|
||||||
|
|
||||||
$song->update($request->validated());
|
$song->update($request->validated());
|
||||||
|
|
||||||
|
$this->songPrefixPostfixService->ensure($song);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Song erfolgreich aktualisiert',
|
'message' => 'Song erfolgreich aktualisiert',
|
||||||
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,10 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s
|
||||||
|
|
||||||
private function songHasContent(Song $song): bool
|
private function songHasContent(Song $song): bool
|
||||||
{
|
{
|
||||||
return $song->sections()->whereHas('slides')->exists();
|
return $song->sections()
|
||||||
|
->where(fn ($q) => $q->where('locked', false)->orWhereNull('locked'))
|
||||||
|
->whereHas('slides')
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveLabel(ParsedCcliSection $section): Label
|
private function resolveLabel(ParsedCcliSection $section): Label
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ public function parse(string $rawText): ParsedCcliSong
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($firstSectionIndex === null) {
|
if ($firstSectionIndex === null) {
|
||||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
return $this->parseMetadataOnly($lines, $isMetadataLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
$headerLines = array_values(array_filter(
|
$headerLines = array_values(array_filter(
|
||||||
|
|
@ -136,7 +136,9 @@ public function parse(string $rawText): ParsedCcliSong
|
||||||
$parsedSections = $this->mergeTranslatedSections($sections);
|
$parsedSections = $this->mergeTranslatedSections($sections);
|
||||||
|
|
||||||
if ($parsedSections === []) {
|
if ($parsedSections === []) {
|
||||||
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) {
|
||||||
|
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ParsedCcliSong(
|
return new ParsedCcliSong(
|
||||||
|
|
@ -150,6 +152,68 @@ public function parse(string $rawText): ParsedCcliSong
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a song from a paste that contains no lyric sections at all.
|
||||||
|
*
|
||||||
|
* Allowed as long as it is a real CCLI page (title + CCLI number present);
|
||||||
|
* otherwise there is genuinely no song and we throw.
|
||||||
|
*
|
||||||
|
* @param string[] $lines
|
||||||
|
* @param Closure(string): bool $isMetadataLine
|
||||||
|
*/
|
||||||
|
private function parseMetadataOnly(array $lines, Closure $isMetadataLine): ParsedCcliSong
|
||||||
|
{
|
||||||
|
$nonEmpty = array_values(array_filter(
|
||||||
|
$lines,
|
||||||
|
fn (string $line): bool => $line !== '',
|
||||||
|
));
|
||||||
|
|
||||||
|
$ccliId = null;
|
||||||
|
$year = null;
|
||||||
|
$copyrightText = null;
|
||||||
|
|
||||||
|
foreach ($nonEmpty as $line) {
|
||||||
|
if (! $isMetadataLine($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extractedCcliId = CcliLabels::extractCcliId($line);
|
||||||
|
if ($extractedCcliId !== null) {
|
||||||
|
$ccliId = $extractedCcliId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($line, '©')) {
|
||||||
|
$copyrightText = $line;
|
||||||
|
|
||||||
|
if (preg_match('/©\s*(\d{4})/u', $line, $matches)) {
|
||||||
|
$year = $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerLines = array_values(array_filter(
|
||||||
|
$nonEmpty,
|
||||||
|
fn (string $line): bool => ! $isMetadataLine($line),
|
||||||
|
));
|
||||||
|
|
||||||
|
$title = $headerLines[0] ?? '';
|
||||||
|
$author = $headerLines[1] ?? null;
|
||||||
|
|
||||||
|
if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) {
|
||||||
|
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedCcliSong(
|
||||||
|
title: $title,
|
||||||
|
author: $author,
|
||||||
|
ccliId: $ccliId,
|
||||||
|
year: $year,
|
||||||
|
copyrightText: $copyrightText,
|
||||||
|
sourceUrl: null,
|
||||||
|
sections: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections
|
* @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections
|
||||||
* @return ParsedCcliSection[]
|
* @return ParsedCcliSection[]
|
||||||
|
|
|
||||||
|
|
@ -27,20 +27,20 @@ public function build(string $name, string $title): ?array
|
||||||
if ($macro !== null) {
|
if ($macro !== null) {
|
||||||
$collection = $macro->collections->first();
|
$collection = $macro->collections->first();
|
||||||
|
|
||||||
return [
|
return $this->withSubtitle([
|
||||||
'text' => $name."\n".$title,
|
'text' => $name,
|
||||||
'macro' => [
|
'macro' => [
|
||||||
'name' => $macro->name,
|
'name' => $macro->name,
|
||||||
'uuid' => $macro->uuid,
|
'uuid' => $macro->uuid,
|
||||||
'collectionName' => $collection?->name ?? '--MAIN--',
|
'collectionName' => $collection?->name ?? '--MAIN--',
|
||||||
'collectionUuid' => $collection?->uuid ?? null,
|
'collectionUuid' => $collection?->uuid ?? null,
|
||||||
],
|
],
|
||||||
];
|
], $title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// macro_id is configured but the Macro record is missing — still return a slide
|
// macro_id is configured but the Macro record is missing — still return a slide
|
||||||
// without a macro so the name tag is not silently dropped.
|
// without a macro so the name tag is not silently dropped.
|
||||||
return ['text' => $name."\n".$title];
|
return $this->withSubtitle(['text' => $name], $title);
|
||||||
}
|
}
|
||||||
|
|
||||||
$macroName = Setting::get('namenseinblender_macro_name');
|
$macroName = Setting::get('namenseinblender_macro_name');
|
||||||
|
|
@ -49,14 +49,30 @@ public function build(string $name, string $title): ?array
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return $this->withSubtitle([
|
||||||
'text' => $name."\n".$title,
|
'text' => $name,
|
||||||
'macro' => [
|
'macro' => [
|
||||||
'name' => $macroName,
|
'name' => $macroName,
|
||||||
'uuid' => Setting::get('namenseinblender_macro_uuid'),
|
'uuid' => Setting::get('namenseinblender_macro_uuid'),
|
||||||
'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
|
'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
|
||||||
'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'),
|
'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'),
|
||||||
],
|
],
|
||||||
];
|
], $title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the role line as a separate `subtitle` key so the generator can render
|
||||||
|
* it smaller and non-bold. Only set when the title is non-empty.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $slideData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withSubtitle(array $slideData, string $title): array
|
||||||
|
{
|
||||||
|
if (trim($title) !== '') {
|
||||||
|
$slideData['subtitle'] = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slideData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use ProPresenter\Parser\ProFileGenerator;
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||||
|
use ProPresenter\Parser\Zip64Fixer;
|
||||||
use ZipArchive;
|
use ZipArchive;
|
||||||
|
|
||||||
class PlaylistExportService
|
class PlaylistExportService
|
||||||
{
|
{
|
||||||
/** @return array{path: string, filename: string, skipped: int} */
|
/** @return array{path: string, filename: string, skipped: int, warnings: array<int, string>} */
|
||||||
public function generatePlaylist(Service $service, bool $preview = false): array
|
public function generatePlaylist(Service $service, bool $preview = false): array
|
||||||
{
|
{
|
||||||
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
||||||
|
|
@ -38,7 +39,7 @@ public function generatePlaylist(Service $service, bool $preview = false): array
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, ServiceAgendaItem> $agendaItems
|
* @param Collection<int, ServiceAgendaItem> $agendaItems
|
||||||
* @return array{path: string, filename: string, skipped: int, temp_dir: string}
|
* @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
|
||||||
*/
|
*/
|
||||||
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
|
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +64,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$playlistItems = [];
|
$playlistItems = [];
|
||||||
$embeddedFiles = [];
|
$embeddedFiles = [];
|
||||||
$skippedUnmatched = 0;
|
$skippedUnmatched = 0;
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
$moderatorSlideData = $this->buildModeratorSlideData($service);
|
$moderatorSlideData = $this->buildModeratorSlideData($service);
|
||||||
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
|
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
|
||||||
|
|
@ -105,8 +107,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
if ($serviceSong->song_id && $serviceSong->song) {
|
if ($serviceSong->song_id && $serviceSong->song) {
|
||||||
$song = $serviceSong->song;
|
$song = $serviceSong->song;
|
||||||
|
|
||||||
if ($this->countSongLabels($song) === 0) {
|
if ($this->countSongContentSlides($song) === 0) {
|
||||||
$skippedUnmatched++;
|
$skippedUnmatched++;
|
||||||
|
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +225,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
'path' => $outputPath,
|
'path' => $outputPath,
|
||||||
'filename' => $outputFilename,
|
'filename' => $outputFilename,
|
||||||
'skipped' => $skippedUnmatched,
|
'skipped' => $skippedUnmatched,
|
||||||
|
'warnings' => $warnings,
|
||||||
'temp_dir' => $tempDir,
|
'temp_dir' => $tempDir,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +234,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
* Legacy export for services without agenda items.
|
* Legacy export for services without agenda items.
|
||||||
* Hardcoded block order: info → songs → moderation → sermon.
|
* Hardcoded block order: info → songs → moderation → sermon.
|
||||||
*
|
*
|
||||||
* @return array{path: string, filename: string, skipped: int, temp_dir: string}
|
* @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
|
||||||
*/
|
*/
|
||||||
private function generatePlaylistLegacy(Service $service, bool $preview = false): array
|
private function generatePlaylistLegacy(Service $service, bool $preview = false): array
|
||||||
{
|
{
|
||||||
|
|
@ -244,6 +248,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
||||||
|
|
||||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||||
$skippedEmpty = 0;
|
$skippedEmpty = 0;
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
$exportService = app(ProExportService::class);
|
$exportService = app(ProExportService::class);
|
||||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||||
|
|
@ -264,8 +269,11 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
||||||
foreach ($matchedSongs as $serviceSong) {
|
foreach ($matchedSongs as $serviceSong) {
|
||||||
$song = $serviceSong->song;
|
$song = $serviceSong->song;
|
||||||
|
|
||||||
if (! $song || $this->countSongLabels($song) === 0) {
|
if (! $song || $this->countSongContentSlides($song) === 0) {
|
||||||
$skippedEmpty++;
|
$skippedEmpty++;
|
||||||
|
if ($song !== null) {
|
||||||
|
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +338,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
|
||||||
'path' => $outputPath,
|
'path' => $outputPath,
|
||||||
'filename' => $outputFilename,
|
'filename' => $outputFilename,
|
||||||
'skipped' => $skippedUnmatched + $skippedEmpty,
|
'skipped' => $skippedUnmatched + $skippedEmpty,
|
||||||
|
'warnings' => $warnings,
|
||||||
'temp_dir' => $tempDir,
|
'temp_dir' => $tempDir,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +375,7 @@ private function addSlidesFromCollection(
|
||||||
'media' => $imageFilename,
|
'media' => $imageFilename,
|
||||||
'format' => 'JPG',
|
'format' => 'JPG',
|
||||||
'label' => $slide->original_filename,
|
'label' => $slide->original_filename,
|
||||||
|
'bundleRelative' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||||
|
|
@ -561,44 +571,19 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri
|
||||||
|
|
||||||
if ($ext === 'probundle') {
|
if ($ext === 'probundle') {
|
||||||
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
|
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
|
||||||
$zip = new ZipArchive;
|
|
||||||
|
|
||||||
if ($zip->open($storedAbsPath) !== true) {
|
$extracted = $this->extractProBundle($storedAbsPath);
|
||||||
|
if ($extracted === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proBytes = null;
|
[$proBytes, $mediaFiles] = $extracted;
|
||||||
|
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
foreach ($mediaFiles as $entryName => $entryBytes) {
|
||||||
$entryName = $zip->getNameIndex($i);
|
$mediaName = basename((string) $entryName);
|
||||||
if ($entryName === false) {
|
if (! isset($embedded[$mediaName])) {
|
||||||
continue;
|
$embedded[$mediaName] = $entryBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_ends_with($entryName, '/')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
|
|
||||||
$entryBytes = $zip->getFromIndex($i);
|
|
||||||
if ($entryBytes === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($entryExt === 'pro') {
|
|
||||||
$proBytes = $entryBytes;
|
|
||||||
} else {
|
|
||||||
$mediaName = basename($entryName);
|
|
||||||
if (! isset($embedded[$mediaName])) {
|
|
||||||
$embedded[$mediaName] = $entryBytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
if ($proBytes === null) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$embedded[$embeddedProName] = $proBytes;
|
$embedded[$embeddedProName] = $proBytes;
|
||||||
|
|
@ -625,6 +610,90 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri
|
||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
|
||||||
|
*
|
||||||
|
* Uses raw ZipArchive extraction so the original .pro bytes are preserved exactly
|
||||||
|
* (no protobuf round-trip). ProPresenter-authored bundles use Zip64; Zip64Fixer is
|
||||||
|
* applied first so those archives open correctly. Normal (non-Zip64) archives pass
|
||||||
|
* through Zip64Fixer unchanged. Returns null when the bundle has no .pro entry.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: array<string, string>}|null [proBytes, mediaFiles]
|
||||||
|
*/
|
||||||
|
private function extractProBundle(string $absPath): ?array
|
||||||
|
{
|
||||||
|
$rawBytes = @file_get_contents($absPath);
|
||||||
|
if ($rawBytes === false || $rawBytes === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fixedBytes = Zip64Fixer::fix($rawBytes);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Not Zip64-fixable: fall back to the original bytes.
|
||||||
|
$fixedBytes = $rawBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempPath = tempnam(sys_get_temp_dir(), 'export-probundle-');
|
||||||
|
if ($tempPath === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$isOpen = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (@file_put_contents($tempPath, $fixedBytes) === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($zip->open($tempPath) !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$isOpen = true;
|
||||||
|
|
||||||
|
$proFilename = null;
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if ($name !== false && str_ends_with(strtolower($name), '.pro')) {
|
||||||
|
$proFilename = $name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($proFilename === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$proBytes = $zip->getFromName($proFilename);
|
||||||
|
if ($proBytes === false || $proBytes === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaFiles = [];
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if ($name === false || $name === $proFilename) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = $zip->getFromIndex($i);
|
||||||
|
if ($contents === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaFiles[$name] = $contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$proBytes, $mediaFiles];
|
||||||
|
} finally {
|
||||||
|
if ($isOpen) {
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
@unlink($tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
{
|
{
|
||||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||||
|
|
@ -684,12 +753,45 @@ private function writeProAndEmbed(string $name, array $slideData, string $tempDi
|
||||||
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
|
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countSongLabels(\App\Models\Song $song): int
|
/**
|
||||||
|
* Number of content slides in the song's default arrangement.
|
||||||
|
* Content = SongSlide rows belonging to NON-locked sections (locked false/null)
|
||||||
|
* that have a label. Locked prefix/postfix sections are ignored even if non-empty.
|
||||||
|
*/
|
||||||
|
private function countSongContentSlides(\App\Models\Song $song): int
|
||||||
{
|
{
|
||||||
return $song->arrangements()
|
$song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label');
|
||||||
->withCount('arrangementLabels')
|
|
||||||
->get()
|
$defaultArr = $song->arrangements->firstWhere('is_default', true)
|
||||||
->sum('arrangement_labels_count');
|
?? $song->arrangements->first();
|
||||||
|
|
||||||
|
if ($defaultArr === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$seenSectionIds = [];
|
||||||
|
|
||||||
|
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||||
|
$section = $arrangementSection->section;
|
||||||
|
|
||||||
|
if ($section === null || $section->label === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($section->locked === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($section->id, $seenSectionIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenSectionIds[] = $section->id;
|
||||||
|
|
||||||
|
$count += $section->slides->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function backgroundData(?Service $service): ?array
|
private function backgroundData(?Service $service): ?array
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
|
||||||
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
|
||||||
$song = $agendaItem->serviceSong->song;
|
$song = $agendaItem->serviceSong->song;
|
||||||
|
|
||||||
$labelCount = $song->arrangements()
|
if ($this->countSongContentSlides($song) === 0) {
|
||||||
->withCount('arrangementLabels')
|
throw new RuntimeException('Lied "'.$song->title.'" übersprungen: keine Inhaltsfolien.');
|
||||||
->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);
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
|
||||||
|
|
@ -119,6 +114,7 @@ private function buildBundleFromSlides(
|
||||||
'media' => $imageFilename,
|
'media' => $imageFilename,
|
||||||
'format' => 'JPG',
|
'format' => 'JPG',
|
||||||
'label' => $slide->original_filename,
|
'label' => $slide->original_filename,
|
||||||
|
'bundleRelative' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
|
||||||
|
|
@ -173,6 +169,47 @@ private function buildBundleFromSlides(
|
||||||
return $bundlePath;
|
return $bundlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of content slides in the song's default arrangement.
|
||||||
|
* Content = SongSlide rows belonging to NON-locked sections (locked false/null)
|
||||||
|
* that have a label. Locked prefix/postfix sections are ignored even if non-empty.
|
||||||
|
*/
|
||||||
|
private function countSongContentSlides(\App\Models\Song $song): int
|
||||||
|
{
|
||||||
|
$song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label');
|
||||||
|
|
||||||
|
$defaultArr = $song->arrangements->firstWhere('is_default', true)
|
||||||
|
?? $song->arrangements->first();
|
||||||
|
|
||||||
|
if ($defaultArr === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$seenSectionIds = [];
|
||||||
|
|
||||||
|
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
|
||||||
|
$section = $arrangementSection->section;
|
||||||
|
|
||||||
|
if ($section === null || $section->label === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($section->locked === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($section->id, $seenSectionIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenSectionIds[] = $section->id;
|
||||||
|
|
||||||
|
$count += $section->slides->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
private function backgroundData(?Service $service): ?array
|
private function backgroundData(?Service $service): ?array
|
||||||
{
|
{
|
||||||
if ($this->backgroundSourcePath($service) === null) {
|
if ($this->backgroundSourcePath($service) === null) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ public function ensure(Song $song): void
|
||||||
$prefixLabelId = $this->resolvePrefixLabelId();
|
$prefixLabelId = $this->resolvePrefixLabelId();
|
||||||
$postfixLabelId = $this->resolvePostfixLabelId();
|
$postfixLabelId = $this->resolvePostfixLabelId();
|
||||||
|
|
||||||
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0);
|
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0, 'prefix');
|
||||||
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX);
|
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX, 'postfix');
|
||||||
|
|
||||||
$arrangement = $this->resolveDefaultArrangement($song);
|
$arrangement = $this->resolveDefaultArrangement($song);
|
||||||
|
|
||||||
|
|
@ -64,7 +64,10 @@ private function resolvePostfixLabelId(): int
|
||||||
return $label->id;
|
return $label->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection
|
/**
|
||||||
|
* @param 'prefix'|'postfix' $kind
|
||||||
|
*/
|
||||||
|
private function ensureLockedSection(Song $song, int $labelId, int $orderHint, string $kind): SongSection
|
||||||
{
|
{
|
||||||
$section = SongSection::firstOrCreate(
|
$section = SongSection::firstOrCreate(
|
||||||
['song_id' => $song->id, 'label_id' => $labelId],
|
['song_id' => $song->id, 'label_id' => $labelId],
|
||||||
|
|
@ -74,9 +77,43 @@ private function ensureLockedSection(Song $song, int $labelId, int $orderHint):
|
||||||
$section->update(['locked' => true]);
|
$section->update(['locked' => true]);
|
||||||
$section->slides()->delete();
|
$section->slides()->delete();
|
||||||
|
|
||||||
|
$textContent = $kind === 'prefix'
|
||||||
|
? $this->buildCopyrightText($song)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$section->slides()->create([
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => $textContent,
|
||||||
|
]);
|
||||||
|
|
||||||
return $section;
|
return $section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildCopyrightText(Song $song): string
|
||||||
|
{
|
||||||
|
$title = trim((string) ($song->title ?? $song->name ?? ''));
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
if ($title !== '') {
|
||||||
|
$lines[] = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $song->author) !== '') {
|
||||||
|
$lines[] = trim((string) $song->author);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $song->copyright_text) !== '') {
|
||||||
|
$lines[] = trim((string) $song->copyright_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $song->ccli_id) !== '') {
|
||||||
|
$lines[] = 'CCLI-Liednr. '.trim((string) $song->ccli_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveDefaultArrangement(Song $song): SongArrangement
|
private function resolveDefaultArrangement(Song $song): SongArrangement
|
||||||
{
|
{
|
||||||
return SongArrangement::firstOrCreate(
|
return SongArrangement::firstOrCreate(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { router } from '@inertiajs/vue3'
|
import { Link, router } from '@inertiajs/vue3'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
agendaItem: { type: Object, required: true },
|
agendaItem: { type: Object, required: true },
|
||||||
|
|
@ -14,6 +14,18 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
|
||||||
|
|
||||||
const isMatched = computed(() => serviceSong.value?.song_id != null)
|
const isMatched = computed(() => serviceSong.value?.song_id != null)
|
||||||
|
|
||||||
|
// Matched song id for linking to the SongDB edit modal (Songs/Index reads #song-<id>).
|
||||||
|
const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null)
|
||||||
|
|
||||||
|
const songDetailHref = computed(() =>
|
||||||
|
matchedSongId.value != null ? `${route('songs.index')}#song-${matchedSongId.value}` : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Matched song whose default arrangement has NO content slide -> not verified.
|
||||||
|
const missingContentSlides = computed(
|
||||||
|
() => isMatched.value && serviceSong.value?.has_content_slides === false,
|
||||||
|
)
|
||||||
|
|
||||||
const useTranslation = ref(false)
|
const useTranslation = ref(false)
|
||||||
const downloading = ref(false)
|
const downloading = ref(false)
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
|
|
@ -206,8 +218,30 @@ async function downloadBundle() {
|
||||||
<td class="py-2.5 pr-3 align-top">
|
<td class="py-2.5 pr-3 align-top">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<!-- Song title -->
|
<!-- Song title -->
|
||||||
<span class="font-medium text-gray-900" data-testid="song-agenda-title">
|
<Link
|
||||||
|
v-if="isMatched && songDetailHref"
|
||||||
|
:href="songDetailHref"
|
||||||
|
class="font-medium text-gray-900 underline decoration-gray-300 decoration-dotted underline-offset-2 transition hover:text-emerald-700 hover:decoration-emerald-400"
|
||||||
|
data-testid="song-agenda-title"
|
||||||
|
title="Song in der Song-Datenbank öffnen"
|
||||||
|
>
|
||||||
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
|
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
|
||||||
|
</Link>
|
||||||
|
<span v-else class="font-medium text-gray-900" data-testid="song-agenda-title">
|
||||||
|
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- No content slides warning (matched but default arrangement empty) -->
|
||||||
|
<span
|
||||||
|
v-if="missingContentSlides"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-700"
|
||||||
|
data-testid="song-no-content"
|
||||||
|
title="Standard-Arrangement enthält keine Inhaltsfolien"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
Keine Inhaltsfolien
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- CCLI pill -->
|
<!-- CCLI pill -->
|
||||||
|
|
@ -232,7 +266,12 @@ async function downloadBundle() {
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<!-- Assign status -->
|
<!-- Assign status -->
|
||||||
<span
|
<span
|
||||||
v-if="isMatched"
|
v-if="missingContentSlides"
|
||||||
|
class="inline-flex h-2 w-2 rounded-full bg-red-500"
|
||||||
|
title="Keine Inhaltsfolien"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-else-if="isMatched"
|
||||||
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
|
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
|
||||||
title="Zugeordnet"
|
title="Zugeordnet"
|
||||||
></span>
|
></span>
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,19 @@ watch(search, () => {
|
||||||
|
|
||||||
watch(onlyWithContent, () => fetchSongs(1))
|
watch(onlyWithContent, () => fetchSongs(1))
|
||||||
|
|
||||||
onMounted(() => fetchSongs())
|
onMounted(async () => {
|
||||||
|
await fetchSongs()
|
||||||
|
openSongFromHash()
|
||||||
|
})
|
||||||
|
|
||||||
|
// When navigated from the service page (#song-<id>), open that song's edit modal.
|
||||||
|
function openSongFromHash() {
|
||||||
|
const match = window.location.hash.match(/^#song-(\d+)$/)
|
||||||
|
if (!match) return
|
||||||
|
const songId = Number(match[1])
|
||||||
|
const song = songs.value.find((s) => s.id === songId) ?? { id: songId }
|
||||||
|
openEditModal(song)
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page) {
|
function goToPage(page) {
|
||||||
if (page < 1 || page > meta.value.last_page) return
|
if (page < 1 || page > meta.value.last_page) return
|
||||||
|
|
|
||||||
87
tests/Feature/CcliEmptySongImportTest.php
Normal file
87
tests/Feature/CcliEmptySongImportTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongSlide;
|
||||||
|
use App\Services\CcliImportService;
|
||||||
|
use App\Services\CcliPasteParser;
|
||||||
|
use App\Services\DTO\ParsedCcliSong;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real CCLI page that carries only metadata (title + CCLI number) but no
|
||||||
|
* lyric/section labels — e.g. an instrumental or a stub song.
|
||||||
|
*/
|
||||||
|
function metadataOnlyPaste(): string
|
||||||
|
{
|
||||||
|
return <<<'TXT'
|
||||||
|
Instrumental Intro
|
||||||
|
|
||||||
|
© 2024 Test Publishing House
|
||||||
|
CCLI # 9999500
|
||||||
|
TXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parser akzeptiert Metadaten-only Paste ohne zu werfen und liefert leere Sektionen', function (): void {
|
||||||
|
$parser = new CcliPasteParser;
|
||||||
|
|
||||||
|
$result = $parser->parse(metadataOnlyPaste());
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(ParsedCcliSong::class)
|
||||||
|
->and($result->title)->toBe('Instrumental Intro')
|
||||||
|
->and($result->ccliId)->toBe('9999500')
|
||||||
|
->and($result->year)->toBe('2024')
|
||||||
|
->and($result->sections)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parser wirft weiterhin wenn weder Titel noch CCLI vorhanden', function (): void {
|
||||||
|
$parser = new CcliPasteParser;
|
||||||
|
|
||||||
|
expect(fn () => $parser->parse(" \n \n"))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import von Metadaten-only erzeugt Song ohne Inhalts-Sektionen aber mit Copyright+Blank', function (): void {
|
||||||
|
$service = app(CcliImportService::class);
|
||||||
|
|
||||||
|
$result = $service->import(metadataOnlyPaste());
|
||||||
|
$song = $result['song']->fresh();
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('created')
|
||||||
|
->and($song->ccli_id)->toBe('9999500')
|
||||||
|
->and($song->has_translation)->toBeFalse();
|
||||||
|
|
||||||
|
// normal-Arrangement existiert trotz fehlender Inhalts-Sektionen.
|
||||||
|
$arrangement = $song->arrangements()->where('name', 'normal')->first();
|
||||||
|
expect($arrangement)->not->toBeNull()
|
||||||
|
->and($arrangement->is_default)->toBeTrue();
|
||||||
|
|
||||||
|
// Genau zwei Folien: eine COPYRIGHT-Folie, eine BLANK-Folie.
|
||||||
|
expect(SongSlide::count())->toBe(2);
|
||||||
|
|
||||||
|
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
|
||||||
|
$copyrightSlide = $song->sections()
|
||||||
|
->where('label_id', $prefixLabelId)
|
||||||
|
->first()
|
||||||
|
->slides()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($copyrightSlide->text_content)->toContain('Instrumental Intro')
|
||||||
|
->and($copyrightSlide->text_content)->toContain('CCLI-Liednr. 9999500');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songHasContent bleibt false fuer Metadaten-only Song (Duplikat-Guard behandelt als leer)', function (): void {
|
||||||
|
$service = app(CcliImportService::class);
|
||||||
|
|
||||||
|
// Erster Import legt den leeren Song an.
|
||||||
|
$service->import(metadataOnlyPaste());
|
||||||
|
|
||||||
|
// Zweiter Import darf NICHT als Duplikat geworfen werden, da kein Inhalt.
|
||||||
|
// Status 'restored' (Song existiert), aber keine DuplicateCcliSongException.
|
||||||
|
$result = $service->import(metadataOnlyPaste());
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('restored')
|
||||||
|
->and(Song::where('ccli_id', '9999500')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
@ -45,7 +45,8 @@ function ccliFixture(string $name): string
|
||||||
expect($arrangement)->not->toBeNull()
|
expect($arrangement)->not->toBeNull()
|
||||||
->and($arrangement->is_default)->toBeTrue()
|
->and($arrangement->is_default)->toBeTrue()
|
||||||
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7)
|
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7)
|
||||||
->and(SongSlide::count())->toBe(5);
|
// 5 Inhalts-Folien + COPYRIGHT-Folie + BLANK-Folie (Prefix/Postfix).
|
||||||
|
->and(SongSlide::count())->toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('imports english and german fixture and stores translated slide text', function () {
|
test('imports english and german fixture and stores translated slide text', function () {
|
||||||
|
|
@ -94,7 +95,12 @@ function ccliFixture(string $name): string
|
||||||
->and($song->author)->toBe('Albert Frey')
|
->and($song->author)->toBe('Albert Frey')
|
||||||
->and($arrangement)->not->toBeNull()
|
->and($arrangement)->not->toBeNull()
|
||||||
->and($arrangement->arrangementSections)->toHaveCount(4)
|
->and($arrangement->arrangementSections)->toHaveCount(4)
|
||||||
->and(SongSlide::count())->toBe(7);
|
// 7 Inhalts-Folien + COPYRIGHT-Folie + BLANK-Folie (Prefix/Postfix).
|
||||||
|
->and(SongSlide::count())->toBe(9);
|
||||||
|
|
||||||
|
// Gesperrte COPYRIGHT/BLANK-Sektionen tragen je eine eigene Folie.
|
||||||
|
$lockedSlides = SongSlide::whereHas('section', fn ($q) => $q->where('locked', true))->count();
|
||||||
|
expect($lockedSlides)->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses distinct label colors for imported section kinds', function () {
|
test('uses distinct label colors for imported section kinds', function () {
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,22 @@ function ccliFixtureContent(string $filename): string
|
||||||
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
|
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parse throws InvalidArgumentException on text with no section labels', function (): void {
|
test('parse erlaubt Metadaten-only Seite (Titel + CCLI, keine Sektionen)', function (): void {
|
||||||
$parser = new CcliPasteParser;
|
$parser = new CcliPasteParser;
|
||||||
|
|
||||||
expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class);
|
// Echte CCLI-Seite ohne Liedtext-Sektionen: Import muss möglich sein.
|
||||||
|
$result = $parser->parse("Instrumental\n\n© 2024 Verlag\nCCLI # 9999777");
|
||||||
|
|
||||||
|
expect($result->title)->toBe('Instrumental')
|
||||||
|
->and($result->ccliId)->toBe('9999777')
|
||||||
|
->and($result->sections)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse throws InvalidArgumentException when neither title nor ccli present', function (): void {
|
||||||
|
$parser = new CcliPasteParser;
|
||||||
|
|
||||||
|
// Reiner Whitespace -> kein Song -> Exception.
|
||||||
|
expect(fn () => $parser->parse(" \n \n "))->toThrow(InvalidArgumentException::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parse error messages are in German', function (): void {
|
test('parse error messages are in German', function (): void {
|
||||||
|
|
|
||||||
154
tests/Feature/ExportBundleRelativeMediaTest.php
Normal file
154
tests/Feature/ExportBundleRelativeMediaTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Services\PlaylistExportService;
|
||||||
|
use App\Services\ProBundleExportService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ProPresenter\Parser\ProBundleReader;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 2: Foreground image slide data (information/moderation/sermon blocks) must
|
||||||
|
* carry 'bundleRelative' => true so the .pro references the embedded image by a
|
||||||
|
* bundle-relative resource URL instead of an absolute path. Otherwise the slide
|
||||||
|
* renders blank on the presenter PC.
|
||||||
|
*/
|
||||||
|
final class ExportBundleRelativeMediaTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Storage::fake('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSlideFile(string $storedFilename): void
|
||||||
|
{
|
||||||
|
$image = imagecreatetruecolor(1920, 1080);
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($image);
|
||||||
|
$contents = ob_get_clean();
|
||||||
|
imagedestroy($image);
|
||||||
|
Storage::disk('public')->put($storedFilename, $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSlide(array $attributes): Slide
|
||||||
|
{
|
||||||
|
return Slide::create(array_merge([
|
||||||
|
'thumbnail_filename' => 'thumb.jpg',
|
||||||
|
'uploaded_at' => now()->subDay(),
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_playlist_export_setzt_bundle_relative_auf_info_folien_slide_daten(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now(), 'date' => now()]);
|
||||||
|
|
||||||
|
$this->createSlideFile('slides/info1.jpg');
|
||||||
|
$this->createSlide([
|
||||||
|
'type' => 'information',
|
||||||
|
'service_id' => null,
|
||||||
|
'original_filename' => 'info1.jpg',
|
||||||
|
'stored_filename' => 'slides/info1.jpg',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$capturedGroups = [];
|
||||||
|
|
||||||
|
$exportService = new class($capturedGroups) extends PlaylistExportService
|
||||||
|
{
|
||||||
|
/** @var array<int, array> */
|
||||||
|
private array $captured;
|
||||||
|
|
||||||
|
public function __construct(array &$captured)
|
||||||
|
{
|
||||||
|
$this->captured = &$captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
|
{
|
||||||
|
$this->captured[] = $groups;
|
||||||
|
file_put_contents($path, 'mock-pro:'.$name);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
|
{
|
||||||
|
file_put_contents($path, 'mock-playlist:'.$name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = $exportService->generatePlaylist($service);
|
||||||
|
|
||||||
|
// Find the slide data array carrying our image media.
|
||||||
|
$found = false;
|
||||||
|
foreach ($capturedGroups as $groups) {
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
foreach ($group['slides'] ?? [] as $slideData) {
|
||||||
|
if (isset($slideData['media'])) {
|
||||||
|
$found = true;
|
||||||
|
$this->assertArrayHasKey('bundleRelative', $slideData);
|
||||||
|
$this->assertTrue($slideData['bundleRelative']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue($found, 'Expected at least one media slide in captured pro groups.');
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_probundle_export_referenziert_vordergrund_medien_bundle_relativ(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now(), 'date' => now()]);
|
||||||
|
|
||||||
|
$this->createSlideFile('slides/mod1.jpg');
|
||||||
|
$this->createSlide([
|
||||||
|
'type' => 'moderation',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'original_filename' => 'mod1.jpg',
|
||||||
|
'stored_filename' => 'slides/mod1.jpg',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'moderation');
|
||||||
|
|
||||||
|
$this->assertFileExists($bundlePath);
|
||||||
|
|
||||||
|
$bundle = ProBundleReader::read($bundlePath);
|
||||||
|
$slides = $bundle->getSong()->getSlides();
|
||||||
|
|
||||||
|
$mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia());
|
||||||
|
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.');
|
||||||
|
|
||||||
|
foreach ($mediaSlides as $slide) {
|
||||||
|
$url = $slide->getMediaUrl();
|
||||||
|
$this->assertNotNull($url);
|
||||||
|
// Bundle-relative resources use a bare basename (no absolute path / no slash).
|
||||||
|
$this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($bundlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupTempDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
tests/Feature/ExportPreAddedProBundleTest.php
Normal file
160
tests/Feature/ExportPreAddedProBundleTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\ExportProFile;
|
||||||
|
use App\Models\Label;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Services\PlaylistExportService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ProPresenter\Parser\PresentationBundle;
|
||||||
|
use ProPresenter\Parser\ProBundleWriter;
|
||||||
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 3: A pre-added .probundle (prefix/postfix ExportProFile) must be unpacked
|
||||||
|
* with the parser's ProBundleReader (Zip64-aware) so the embedded .pro bytes are
|
||||||
|
* read correctly and the presentation appears in the exported playlist.
|
||||||
|
*/
|
||||||
|
final class ExportPreAddedProBundleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Storage::fake('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSongWithContent(string $title = 'Test Song'): Song
|
||||||
|
{
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => $title,
|
||||||
|
'ccli_id' => fake()->unique()->numerify('#####'),
|
||||||
|
'author' => 'Test Author',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$label = Label::firstOrCreate(['name' => 'Verse 1 - '.$title], ['color' => '#2196F3']);
|
||||||
|
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
|
||||||
|
$section->slides()->create(['order' => 0, 'text_content' => 'Inhalt']);
|
||||||
|
|
||||||
|
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||||
|
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||||
|
|
||||||
|
return $song;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal valid .probundle and register it as a prefix ExportProFile on the local disk. */
|
||||||
|
private function registerPrefixBundle(string $proName = 'IntroBundle'): ExportProFile
|
||||||
|
{
|
||||||
|
$song = ProFileGenerator::generate(
|
||||||
|
$proName,
|
||||||
|
[[
|
||||||
|
'name' => 'Intro',
|
||||||
|
'color' => [0, 0, 0, 1],
|
||||||
|
'slides' => [['text' => 'Willkommen']],
|
||||||
|
]],
|
||||||
|
[['name' => 'normal', 'groupNames' => ['Intro']]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$bundle = new PresentationBundle($song, $proName.'.pro', []);
|
||||||
|
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'fixture-').'.probundle';
|
||||||
|
ProBundleWriter::write($bundle, $tmp);
|
||||||
|
$bytes = file_get_contents($tmp);
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
$storedPath = 'export-pro-files/'.$proName.'.probundle';
|
||||||
|
Storage::disk('local')->put($storedPath, $bytes);
|
||||||
|
|
||||||
|
return ExportProFile::create([
|
||||||
|
'type' => 'prefix',
|
||||||
|
'original_name' => $proName.'.probundle',
|
||||||
|
'stored_path' => $storedPath,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_vorab_hinzugefuegtes_probundle_ergibt_nicht_leere_playlist(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Mit Prefix']);
|
||||||
|
$song = $this->createSongWithContent('Hauptlied');
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'cts_song_name' => 'Hauptlied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->registerPrefixBundle('IntroBundle');
|
||||||
|
|
||||||
|
$capturedItems = [];
|
||||||
|
$capturedFiles = [];
|
||||||
|
|
||||||
|
$exportService = new class($capturedItems, $capturedFiles) extends PlaylistExportService
|
||||||
|
{
|
||||||
|
/** @var array<int, array> */
|
||||||
|
private array $items;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $files;
|
||||||
|
|
||||||
|
public function __construct(array &$items, array &$files)
|
||||||
|
{
|
||||||
|
$this->items = &$items;
|
||||||
|
$this->files = &$files;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
|
{
|
||||||
|
file_put_contents($path, 'mock-pro:'.$name);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
|
{
|
||||||
|
$this->items = $items;
|
||||||
|
$this->files = $embeddedFiles;
|
||||||
|
file_put_contents($path, 'mock-playlist:'.$name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$result = $exportService->generatePlaylist($service);
|
||||||
|
|
||||||
|
$this->assertFileExists($result['path']);
|
||||||
|
|
||||||
|
// The prefix .pro must be present as a playlist item and embedded with non-empty bytes.
|
||||||
|
$prefixItem = collect($capturedItems)->first(
|
||||||
|
fn ($item) => str_starts_with($item['path'] ?? '', 'PREFIX_') && str_ends_with($item['path'], '.pro')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNotNull($prefixItem, 'Pre-added .probundle should produce a PREFIX_*.pro playlist item.');
|
||||||
|
|
||||||
|
$embeddedKey = $prefixItem['path'];
|
||||||
|
$this->assertArrayHasKey($embeddedKey, $capturedFiles);
|
||||||
|
$this->assertNotSame('', $capturedFiles[$embeddedKey], 'Embedded .pro from .probundle must be non-empty.');
|
||||||
|
$this->assertGreaterThan(0, strlen($capturedFiles[$embeddedKey]));
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupTempDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ private function buildProbundleZip(string $proContent, array $mediaFiles = []):
|
||||||
{
|
{
|
||||||
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive;
|
||||||
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||||
$zip->addFromString('song.pro', $proContent);
|
$zip->addFromString('song.pro', $proContent);
|
||||||
|
|
||||||
|
|
@ -206,7 +206,7 @@ public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive;
|
||||||
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||||
$zip->addFromString('media.jpg', 'media-only');
|
$zip->addFromString('media.jpg', 'media-only');
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
|
||||||
199
tests/Feature/ExportSkipEmptyLockedSongTest.php
Normal file
199
tests/Feature/ExportSkipEmptyLockedSongTest.php
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Label;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Services\PlaylistExportService;
|
||||||
|
use App\Services\ProBundleExportService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIX 5: A song whose default arrangement contains only EMPTY locked prefix/postfix
|
||||||
|
* sections has zero content slides. It must be skipped (not exported as an empty .pro)
|
||||||
|
* and a German warning must be surfaced. A normal song still exports.
|
||||||
|
*/
|
||||||
|
final class ExportSkipEmptyLockedSongTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Storage::fake('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Song whose default arrangement only references an empty, locked section. */
|
||||||
|
private function createEmptyLockedSong(string $title = 'Leeres Lied'): Song
|
||||||
|
{
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => $title,
|
||||||
|
'ccli_id' => fake()->unique()->numerify('#####'),
|
||||||
|
'author' => 'Author',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$label = Label::firstOrCreate(['name' => 'Intro - '.$title], ['color' => '#000000']);
|
||||||
|
// Locked section with NO slides.
|
||||||
|
$section = $song->sections()->create([
|
||||||
|
'label_id' => $label->id,
|
||||||
|
'order' => 0,
|
||||||
|
'locked' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||||
|
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||||
|
|
||||||
|
return $song;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNormalSong(string $title = 'Normales Lied'): Song
|
||||||
|
{
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => $title,
|
||||||
|
'ccli_id' => fake()->unique()->numerify('#####'),
|
||||||
|
'author' => 'Author',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$label = Label::firstOrCreate(['name' => 'Verse 1 - '.$title], ['color' => '#2196F3']);
|
||||||
|
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0, 'locked' => false]);
|
||||||
|
$section->slides()->create(['order' => 0, 'text_content' => 'Strophentext']);
|
||||||
|
|
||||||
|
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||||
|
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
|
||||||
|
|
||||||
|
return $song;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testable_export_service(): PlaylistExportService
|
||||||
|
{
|
||||||
|
return new class extends PlaylistExportService
|
||||||
|
{
|
||||||
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
|
{
|
||||||
|
file_put_contents($path, 'mock-pro:'.$name);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
|
{
|
||||||
|
$content = 'mock-playlist:'.$name;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$content .= "\n".($item['name'] ?? '');
|
||||||
|
}
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_playlist_legacy_ueberspringt_leeres_locked_lied_mit_warnung(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Mix Service']);
|
||||||
|
|
||||||
|
$emptySong = $this->createEmptyLockedSong('Leeres Lied');
|
||||||
|
$normalSong = $this->createNormalSong('Normales Lied');
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $emptySong->id,
|
||||||
|
'cts_song_name' => 'Leeres Lied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $normalSong->id,
|
||||||
|
'cts_song_name' => 'Normales Lied',
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->testable_export_service()->generatePlaylist($service);
|
||||||
|
|
||||||
|
$content = file_get_contents($result['path']);
|
||||||
|
|
||||||
|
// Empty locked song skipped, normal song exported.
|
||||||
|
$this->assertStringNotContainsString('Leeres Lied', $content);
|
||||||
|
$this->assertStringContainsString('Normales Lied', $content);
|
||||||
|
|
||||||
|
// German warning present.
|
||||||
|
$this->assertContains(
|
||||||
|
"Lied 'Leeres Lied' übersprungen: keine Inhaltsfolien.",
|
||||||
|
$result['warnings'],
|
||||||
|
);
|
||||||
|
$this->assertGreaterThanOrEqual(1, $result['skipped']);
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_probundle_agenda_song_wirft_fehler_bei_leerem_locked_lied(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now()]);
|
||||||
|
$emptySong = $this->createEmptyLockedSong('Leeres Lied');
|
||||||
|
|
||||||
|
$serviceSong = ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $emptySong->id,
|
||||||
|
'cts_song_name' => 'Leeres Lied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agendaItem = \App\Models\ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Leeres Lied',
|
||||||
|
'service_song_id' => $serviceSong->id,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('keine Inhaltsfolien');
|
||||||
|
|
||||||
|
app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_probundle_agenda_song_exportiert_normales_lied(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now()]);
|
||||||
|
$normalSong = $this->createNormalSong('Normales Lied');
|
||||||
|
|
||||||
|
$serviceSong = ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $normalSong->id,
|
||||||
|
'cts_song_name' => 'Normales Lied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agendaItem = \App\Models\ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Normales Lied',
|
||||||
|
'service_song_id' => $serviceSong->id,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
|
||||||
|
|
||||||
|
$this->assertFileExists($bundlePath);
|
||||||
|
$this->assertGreaterThan(0, filesize($bundlePath));
|
||||||
|
|
||||||
|
@unlink($bundlePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupTempDir(string $dir): void
|
||||||
|
{
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,13 +20,14 @@
|
||||||
$slide = app(NameTagSlideBuilder::class)->build('Anna Müller', 'Moderation');
|
$slide = app(NameTagSlideBuilder::class)->build('Anna Müller', 'Moderation');
|
||||||
|
|
||||||
expect($slide)->toBe([
|
expect($slide)->toBe([
|
||||||
'text' => "Anna Müller\nModeration",
|
'text' => 'Anna Müller',
|
||||||
'macro' => [
|
'macro' => [
|
||||||
'name' => 'Namenseinblender',
|
'name' => 'Namenseinblender',
|
||||||
'uuid' => '11111111-1111-1111-1111-111111111111',
|
'uuid' => '11111111-1111-1111-1111-111111111111',
|
||||||
'collectionName' => 'Service Macros',
|
'collectionName' => 'Service Macros',
|
||||||
'collectionUuid' => '22222222-2222-2222-2222-222222222222',
|
'collectionUuid' => '22222222-2222-2222-2222-222222222222',
|
||||||
],
|
],
|
||||||
|
'subtitle' => 'Moderation',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -38,8 +39,13 @@
|
||||||
|
|
||||||
$builder = app(NameTagSlideBuilder::class);
|
$builder = app(NameTagSlideBuilder::class);
|
||||||
|
|
||||||
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
|
$moderatorSlide = $builder->buildModeratorSlide('Max Mustermann');
|
||||||
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt");
|
$preacherSlide = $builder->buildPreacherSlide('Erika Beispiel');
|
||||||
|
|
||||||
|
expect($moderatorSlide['text'])->toBe('Max Mustermann')
|
||||||
|
->and($moderatorSlide['subtitle'])->toBe('Moderation')
|
||||||
|
->and($preacherSlide['text'])->toBe('Erika Beispiel')
|
||||||
|
->and($preacherSlide['subtitle'])->toBe('Predigt');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('build returns slide with macro when macro_id points to existing macro', function () {
|
test('build returns slide with macro when macro_id points to existing macro', function () {
|
||||||
|
|
@ -51,7 +57,8 @@
|
||||||
|
|
||||||
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
||||||
|
|
||||||
expect($slide)->toHaveKey('text', "Lea Leiter\nModeration")
|
expect($slide)->toHaveKey('text', 'Lea Leiter')
|
||||||
|
->and($slide)->toHaveKey('subtitle', 'Moderation')
|
||||||
->and($slide)->toHaveKey('macro')
|
->and($slide)->toHaveKey('macro')
|
||||||
->and($slide['macro']['name'])->toBe('Namenseinblender')
|
->and($slide['macro']['name'])->toBe('Namenseinblender')
|
||||||
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
||||||
|
|
@ -62,5 +69,5 @@
|
||||||
|
|
||||||
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
|
||||||
|
|
||||||
expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]);
|
expect($slide)->toBe(['text' => 'Lea Leiter', 'subtitle' => 'Moderation']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,11 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
|
||||||
|
|
||||||
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
|
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
|
||||||
$this->assertCount(1, $nameTagSlides);
|
$this->assertCount(1, $nameTagSlides);
|
||||||
$this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText());
|
// Name and role are now split: the name is the main (\fs84) run and the
|
||||||
|
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
|
||||||
|
[$name, $subtitle] = $this->nameTagNameAndSubtitle($nameTagSlides[0]);
|
||||||
|
$this->assertSame('Erika Predigt', $name);
|
||||||
|
$this->assertSame('Predigt', $subtitle);
|
||||||
$this->assertTrue($nameTagSlides[0]->hasMacro());
|
$this->assertTrue($nameTagSlides[0]->hasMacro());
|
||||||
|
|
||||||
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
|
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
|
||||||
|
|
@ -116,7 +120,10 @@ public function test_moderator_nametag_is_first_presentation_for_first_visible_a
|
||||||
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
|
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
|
||||||
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
|
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
|
||||||
$this->assertCount(1, $moderatorSlides);
|
$this->assertCount(1, $moderatorSlides);
|
||||||
$this->assertSame("Max Moderation\nModeration", $moderatorSlides[0]->getPlainText());
|
// Name and role are split: the name is the main run, the role the subtitle run.
|
||||||
|
[$name, $subtitle] = $this->nameTagNameAndSubtitle($moderatorSlides[0]);
|
||||||
|
$this->assertSame('Max Moderation', $name);
|
||||||
|
$this->assertSame('Moderation', $subtitle);
|
||||||
$this->assertTrue($moderatorSlides[0]->hasMacro());
|
$this->assertTrue($moderatorSlides[0]->hasMacro());
|
||||||
|
|
||||||
$this->cleanupTempDir($result['temp_dir']);
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
|
@ -252,6 +259,28 @@ private function slidesForEntry(PlaylistArchive $playlist, $entry): array
|
||||||
return $this->allParserSlides($playlist->getEmbeddedSong($filename));
|
return $this->allParserSlides($playlist->getEmbeddedSong($filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a name-tag slide into its [name, subtitle] parts. The generator emits
|
||||||
|
* the name as the main (\fs84) run and the role as a separate smaller,
|
||||||
|
* non-bold (\b0\fs50) subtitle run on a new line inside the same RTF element.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string}
|
||||||
|
*/
|
||||||
|
private function nameTagNameAndSubtitle(\ProPresenter\Parser\Slide $slide): array
|
||||||
|
{
|
||||||
|
$elements = $slide->getTextElements();
|
||||||
|
$this->assertNotEmpty($elements);
|
||||||
|
$rtf = $elements[0]->getRtfData();
|
||||||
|
$this->assertStringContainsString('\b0\fs50', $rtf, 'Name-tag slide is missing the subtitle run');
|
||||||
|
|
||||||
|
// getPlainText() returns both runs joined by a newline ("Name\nRole").
|
||||||
|
$plain = $slide->getPlainText();
|
||||||
|
$parts = explode("\n", $plain, 2);
|
||||||
|
$this->assertCount(2, $parts, 'Name-tag slide does not contain a name and a subtitle line');
|
||||||
|
|
||||||
|
return [trim($parts[0]), trim($parts[1])];
|
||||||
|
}
|
||||||
|
|
||||||
private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array
|
private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array
|
||||||
{
|
{
|
||||||
$this->assertNotNull($parserSong);
|
$this->assertNotNull($parserSong);
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,15 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
|
||||||
$this->assertNotNull($song);
|
$this->assertNotNull($song);
|
||||||
|
|
||||||
$this->assertSame(6, \App\Models\Label::count());
|
$this->assertSame(6, \App\Models\Label::count());
|
||||||
$this->assertSame(5, \App\Models\SongSlide::count());
|
|
||||||
|
// The imported lyric content is 5 slides. SongPrefixPostfixService::ensure()
|
||||||
|
// now always adds a COPYRIGHT slide + a BLANK slide in locked sections, so
|
||||||
|
// the 5 content slides live in non-locked sections and the total is 7.
|
||||||
|
$contentSlideCount = \App\Models\SongSlide::whereHas('section', function ($query): void {
|
||||||
|
$query->where('locked', false)->orWhereNull('locked');
|
||||||
|
})->count();
|
||||||
|
$this->assertSame(5, $contentSlideCount);
|
||||||
|
$this->assertSame(7, \App\Models\SongSlide::count());
|
||||||
|
|
||||||
$this->assertSame(2, $song->arrangements()->count());
|
$this->assertSame(2, $song->arrangements()->count());
|
||||||
$this->assertTrue($song->has_translation);
|
$this->assertTrue($song->has_translation);
|
||||||
|
|
|
||||||
184
tests/Feature/ServiceSongContentSlidesTest.php
Normal file
184
tests/Feature/ServiceSongContentSlidesTest.php
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongArrangement;
|
||||||
|
use App\Models\SongArrangementSection;
|
||||||
|
use App\Models\SongSection;
|
||||||
|
use App\Models\SongSlide;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class ServiceSongContentSlidesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function makeServiceWithSong(callable $buildSong): array
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'date' => Carbon::today(),
|
||||||
|
'finalized_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$arrangement = $buildSong($song);
|
||||||
|
|
||||||
|
$serviceSong = ServiceSong::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$service, $serviceSong];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultArrangementWithContentSlide(Song $song): SongArrangement
|
||||||
|
{
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = SongSection::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'locked' => false,
|
||||||
|
]);
|
||||||
|
SongSlide::factory()->create(['song_section_id' => $section->id]);
|
||||||
|
|
||||||
|
SongArrangementSection::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_section_id' => $section->id,
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $arrangement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_content_slides_ist_true_wenn_standard_arrangement_inhaltsfolie_hat(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->withoutVite();
|
||||||
|
|
||||||
|
[$service, $serviceSong] = $this->makeServiceWithSong(
|
||||||
|
fn (Song $song) => $this->defaultArrangementWithContentSlide($song)
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::factory()->create())
|
||||||
|
->get(route('services.edit', $service->id));
|
||||||
|
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->where('serviceSongs.0.has_content_slides', true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_content_slides_ist_false_wenn_section_gesperrt(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->withoutVite();
|
||||||
|
|
||||||
|
[$service, $serviceSong] = $this->makeServiceWithSong(function (Song $song): SongArrangement {
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Locked section with slides -> not a content slide.
|
||||||
|
$section = SongSection::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'locked' => true,
|
||||||
|
]);
|
||||||
|
SongSlide::factory()->create(['song_section_id' => $section->id]);
|
||||||
|
|
||||||
|
SongArrangementSection::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_section_id' => $section->id,
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $arrangement;
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::factory()->create())
|
||||||
|
->get(route('services.edit', $service->id));
|
||||||
|
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->where('serviceSongs.0.has_content_slides', false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_content_slides_ist_false_wenn_section_keine_folien_hat(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->withoutVite();
|
||||||
|
|
||||||
|
[$service, $serviceSong] = $this->makeServiceWithSong(function (Song $song): SongArrangement {
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Non-locked section but NO slides -> not a content slide.
|
||||||
|
$section = SongSection::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'locked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementSection::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_section_id' => $section->id,
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $arrangement;
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::factory()->create())
|
||||||
|
->get(route('services.edit', $service->id));
|
||||||
|
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->where('serviceSongs.0.has_content_slides', false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_has_content_slides_ist_null_wenn_kein_song_zugeordnet(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->withoutVite();
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'date' => Carbon::today(),
|
||||||
|
'finalized_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => null,
|
||||||
|
'song_arrangement_id' => null,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Unmatched Song',
|
||||||
|
'cts_ccli_id' => '999999',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::factory()->create())
|
||||||
|
->get(route('services.edit', $service->id));
|
||||||
|
|
||||||
|
$response->assertInertia(
|
||||||
|
fn ($page) => $page
|
||||||
|
->component('Services/Edit')
|
||||||
|
->where('serviceSongs.0.has_content_slides', null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -181,6 +181,33 @@
|
||||||
expect($song->author)->toBe('New Author');
|
expect($song->author)->toBe('New Author');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('update aktualisiert COPYRIGHT-Folie ueber Prefix/Postfix-Service', function () {
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Old Title',
|
||||||
|
'author' => 'Old Author',
|
||||||
|
'ccli_id' => '7654321',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => 'Neuer Titel',
|
||||||
|
'author' => 'Neuer Autor',
|
||||||
|
'ccli_id' => '7654321',
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$prefixLabelId = (int) \App\Models\Setting::get('song_prefix_label_id');
|
||||||
|
$copyrightSlide = \App\Models\SongSection::where('song_id', $song->id)
|
||||||
|
->where('label_id', $prefixLabelId)
|
||||||
|
->first()
|
||||||
|
->slides()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($copyrightSlide)->not->toBeNull()
|
||||||
|
->and($copyrightSlide->text_content)->toContain('Neuer Titel')
|
||||||
|
->and($copyrightSlide->text_content)->toContain('Neuer Autor')
|
||||||
|
->and($copyrightSlide->text_content)->toContain('CCLI-Liednr. 7654321');
|
||||||
|
});
|
||||||
|
|
||||||
test('update validates unique ccli_id excluding self', function () {
|
test('update validates unique ccli_id excluding self', function () {
|
||||||
$songA = Song::factory()->create(['ccli_id' => '111111']);
|
$songA = Song::factory()->create(['ccli_id' => '111111']);
|
||||||
$songB = Song::factory()->create(['ccli_id' => '222222']);
|
$songB = Song::factory()->create(['ccli_id' => '222222']);
|
||||||
|
|
|
||||||
108
tests/Feature/SongPrefixPostfixServiceTest.php
Normal file
108
tests/Feature/SongPrefixPostfixServiceTest.php
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongSection;
|
||||||
|
use App\Models\SongSlide;
|
||||||
|
use App\Services\SongPrefixPostfixService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('ensure populiert COPYRIGHT-Folie mit Attribution inkl. CCLI-Nummer', function (): void {
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => 'Mein Lied',
|
||||||
|
'author' => 'Max Mustermann',
|
||||||
|
'copyright_text' => '© 2024 Verlag',
|
||||||
|
'ccli_id' => '1234567',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SongPrefixPostfixService::class)->ensure($song);
|
||||||
|
|
||||||
|
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
|
||||||
|
$prefixSection = SongSection::where('song_id', $song->id)
|
||||||
|
->where('label_id', $prefixLabelId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($prefixSection)->not->toBeNull()
|
||||||
|
->and($prefixSection->locked)->toBeTrue();
|
||||||
|
|
||||||
|
$slides = $prefixSection->slides()->get();
|
||||||
|
|
||||||
|
expect($slides)->toHaveCount(1);
|
||||||
|
|
||||||
|
$expected = implode("\n", [
|
||||||
|
'Mein Lied',
|
||||||
|
'Max Mustermann',
|
||||||
|
'© 2024 Verlag',
|
||||||
|
'CCLI-Liednr. 1234567',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($slides->first()->text_content)->toBe($expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensure laesst leere Felder in der Attribution weg', function (): void {
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => 'Nur Titel',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SongPrefixPostfixService::class)->ensure($song);
|
||||||
|
|
||||||
|
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
|
||||||
|
$slide = SongSection::where('song_id', $song->id)
|
||||||
|
->where('label_id', $prefixLabelId)
|
||||||
|
->first()
|
||||||
|
->slides()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($slide->text_content)->toBe('Nur Titel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensure erstellt eine leere BLANK-Folie in gesperrter Postfix-Sektion', function (): void {
|
||||||
|
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
|
||||||
|
|
||||||
|
app(SongPrefixPostfixService::class)->ensure($song);
|
||||||
|
|
||||||
|
$postfixLabelId = (int) Setting::get('song_postfix_label_id');
|
||||||
|
$postfixSection = SongSection::where('song_id', $song->id)
|
||||||
|
->where('label_id', $postfixLabelId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($postfixSection)->not->toBeNull()
|
||||||
|
->and($postfixSection->locked)->toBeTrue();
|
||||||
|
|
||||||
|
$slides = $postfixSection->slides()->get();
|
||||||
|
|
||||||
|
expect($slides)->toHaveCount(1)
|
||||||
|
->and($slides->first()->text_content)->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default-Arrangement hat COPYRIGHT zuerst und BLANK zuletzt', function (): void {
|
||||||
|
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
|
||||||
|
|
||||||
|
app(SongPrefixPostfixService::class)->ensure($song);
|
||||||
|
|
||||||
|
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
|
||||||
|
$postfixLabelId = (int) Setting::get('song_postfix_label_id');
|
||||||
|
|
||||||
|
$arrangement = $song->arrangements()->where('name', 'normal')->first();
|
||||||
|
|
||||||
|
$ordered = $arrangement->arrangementSections()
|
||||||
|
->with('section')
|
||||||
|
->get()
|
||||||
|
->sortBy('order')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($ordered->first()->section->label_id)->toBe($prefixLabelId)
|
||||||
|
->and($ordered->last()->section->label_id)->toBe($postfixLabelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensure ist idempotent und erzeugt keine doppelten Folien', function (): void {
|
||||||
|
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
|
||||||
|
|
||||||
|
$service = app(SongPrefixPostfixService::class);
|
||||||
|
$service->ensure($song);
|
||||||
|
$service->ensure($song);
|
||||||
|
|
||||||
|
expect(SongSlide::count())->toBe(2);
|
||||||
|
});
|
||||||
109
tests/e2e/song-agenda-link.spec.ts
Normal file
109
tests/e2e/song-agenda-link.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// FIX 6: matched song title in the service agenda links to the SongDB edit modal
|
||||||
|
// (route('songs.index')#song-<id>); FIX 5: songs whose default arrangement has no
|
||||||
|
// content slides show a "Keine Inhaltsfolien" indicator.
|
||||||
|
//
|
||||||
|
// These tests depend on synced service data. When no editable service / matched song
|
||||||
|
// exists, they skip gracefully (same pattern as service-edit-songs.spec.ts).
|
||||||
|
|
||||||
|
async function navigateToEditPage(page): Promise<boolean> {
|
||||||
|
await page.goto('/services');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const editButton = page.getByTestId('service-list-edit-button').first();
|
||||||
|
const hasEditableService = await editButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasEditableService) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editButton.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('matched song title links to SongDB edit hash', async ({ page }) => {
|
||||||
|
const navigated = await navigateToEditPage(page);
|
||||||
|
if (!navigated) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A matched title renders as an Inertia <Link> (an <a> with data-testid).
|
||||||
|
const titleLink = page.locator('a[data-testid="song-agenda-title"]').first();
|
||||||
|
const hasMatched = await titleLink.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasMatched) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = await titleLink.getAttribute('href');
|
||||||
|
expect(href).toBeTruthy();
|
||||||
|
// route('songs.index') + '#song-<id>'
|
||||||
|
expect(href).toMatch(/\/songs(\/index)?#song-\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking matched song title opens the edit modal on the Songs page', async ({ page }) => {
|
||||||
|
const navigated = await navigateToEditPage(page);
|
||||||
|
if (!navigated) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleLink = page.locator('a[data-testid="song-agenda-title"]').first();
|
||||||
|
const hasMatched = await titleLink.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasMatched) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
await titleLink.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// We land on the Songs page with #song-<id> and the edit modal auto-opens.
|
||||||
|
expect(new URL(page.url()).pathname).toContain('/songs');
|
||||||
|
expect(page.url()).toMatch(/#song-\d+$/);
|
||||||
|
|
||||||
|
const editModal = page.getByTestId('song-list-edit-modal');
|
||||||
|
await expect(editModal).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmatched song title stays plain text (no link)', async ({ page }) => {
|
||||||
|
const navigated = await navigateToEditPage(page);
|
||||||
|
if (!navigated) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unmatched row has a request-creation button; its title is a <span>, not <a>.
|
||||||
|
const requestButton = page.getByTestId('song-request-creation').first();
|
||||||
|
const hasUnmatched = await requestButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasUnmatched) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatchedRow = page
|
||||||
|
.getByTestId('song-agenda-item')
|
||||||
|
.filter({ has: page.getByTestId('song-request-creation') })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const plainTitle = unmatchedRow.locator('span[data-testid="song-agenda-title"]');
|
||||||
|
await expect(plainTitle).toBeVisible();
|
||||||
|
await expect(unmatchedRow.locator('a[data-testid="song-agenda-title"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song without content slides shows "Keine Inhaltsfolien" indicator', async ({ page }) => {
|
||||||
|
const navigated = await navigateToEditPage(page);
|
||||||
|
if (!navigated) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const noContent = page.getByTestId('song-no-content').first();
|
||||||
|
const hasNoContent = await noContent.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasNoContent) {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(noContent).toBeVisible();
|
||||||
|
await expect(noContent).toContainText('Keine Inhaltsfolien');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue