feat: probundle export with media, image upscaling, upload dimension warnings

- Fix probundle exports missing images (double slides/ prefix in storage path)
- Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin
- Add per-agenda-item download route and buttons for songs and slide items
- Remove text layer from image-only slides in .pro generation
- Fix image conversion: upscale small images, black bars on 2 sides max (contain)
- Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking)
- Update SlideFactory and all tests to use slides/ prefix in stored_filename
- Add 11 new tests (agenda download, image conversion, upload warnings)
This commit is contained in:
Thorsten Bus 2026-03-30 10:29:37 +02:00
parent 63f40f8364
commit 0e3c647cfc
19 changed files with 697 additions and 69 deletions

View file

@ -16,6 +16,7 @@
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ServiceController extends Controller class ServiceController extends Controller
@ -388,4 +389,27 @@ public function downloadBundle(Request $request, Service $service, string $block
) )
->deleteFileAfterSend(true); ->deleteFileAfterSend(true);
} }
public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaItem): JsonResponse|BinaryFileResponse
{
if ((int) $agendaItem->service_id !== (int) $service->id) {
abort(404);
}
try {
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $agendaItem->title ?: 'element');
return response()
->download(
$bundlePath,
$safeTitle.'.probundle',
['Content-Type' => 'application/zip']
)
->deleteFileAfterSend(true);
}
} }

View file

@ -175,10 +175,16 @@ private function handleImage(
'sort_order' => $this->nextSortOrder($type, $serviceId), 'sort_order' => $this->nextSortOrder($type, $serviceId),
]); ]);
return response()->json([ $response = [
'success' => true, 'success' => true,
'slide' => $slide, 'slide' => $slide,
]); ];
if (! empty($result['warnings'])) {
$response['warnings'] = $result['warnings'];
}
return response()->json($response);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
@ -237,6 +243,7 @@ private function handleZip(
try { try {
$results = $conversionService->processZip($file); $results = $conversionService->processZip($file);
$slides = []; $slides = [];
$allWarnings = [];
$sortOrder = $this->nextSortOrder($type, $serviceId); $sortOrder = $this->nextSortOrder($type, $serviceId);
@ -258,13 +265,23 @@ private function handleZip(
'uploaded_at' => now(), 'uploaded_at' => now(),
'sort_order' => $sortOrder++, 'sort_order' => $sortOrder++,
]); ]);
if (! empty($result['warnings'])) {
array_push($allWarnings, ...$result['warnings']);
}
} }
return response()->json([ $response = [
'success' => true, 'success' => true,
'slides' => $slides, 'slides' => $slides,
'count' => count($slides), 'count' => count($slides),
]); ];
if (! empty($allWarnings)) {
$response['warnings'] = array_values(array_unique($allWarnings));
}
return response()->json($response);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]); Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]);

View file

@ -41,17 +41,21 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array
$this->ensureDirectory(dirname($targetPath)); $this->ensureDirectory(dirname($targetPath));
$manager = $this->createImageManager(); $manager = $this->createImageManager();
$canvas = $manager->create(1920, 1080)->fill('000000');
$image = $manager->read($sourcePath); $image = $manager->read($sourcePath);
$image->scaleDown(width: 1920, height: 1080);
$canvas->place($image, 'center'); $originalWidth = $image->width();
$canvas->save($targetPath, quality: 90); $originalHeight = $image->height();
$warnings = $this->checkImageDimensions($originalWidth, $originalHeight);
$image->contain(1920, 1080, '000000', 'center');
$image->save($targetPath, quality: 90);
$thumbnailPath = $this->generateThumbnail($relativePath); $thumbnailPath = $this->generateThumbnail($relativePath);
return [ return [
'filename' => $relativePath, 'filename' => $relativePath,
'thumbnail' => $thumbnailPath, 'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
]; ];
} }
@ -252,6 +256,29 @@ private function deleteDirectory(string $directory): void
@rmdir($directory); @rmdir($directory);
} }
/** @return string[] */
private function checkImageDimensions(int $width, int $height): array
{
$warnings = [];
$isExactOrLarger16by9 = $width >= 1920 && $height >= 1080
&& abs($width / $height - 16 / 9) < 0.01;
if (! $isExactOrLarger16by9) {
$warnings[] = 'Das Bild hat nicht das optimale Seitenverhältnis von 16:9. '
.'Für die beste Darstellung verwende bitte Bilder mit exakt 1920×1080 Pixeln. '
.'Das Bild wurde trotzdem verarbeitet, es können aber schwarze Ränder entstehen.';
}
if ($width < 1920 || $height < 1080) {
$warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. "
.'Dadurch kann die Qualität schlechter sein. '
.'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.';
}
return $warnings;
}
private function ensureDirectory(string $directory): void private function ensureDirectory(string $directory): void
{ {
if (is_dir($directory)) { if (is_dir($directory)) {

View file

@ -261,20 +261,19 @@ private function addSlidesFromCollection(
$imageFiles = []; $imageFiles = [];
foreach ($slides->values() as $index => $slide) { foreach ($slides->values() as $index => $slide) {
$storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename); $storedPath = Storage::disk('public')->path($slide->stored_filename);
if (! file_exists($storedPath)) { if (! file_exists($storedPath)) {
continue; continue;
} }
$imageFilename = $prefix.'_'.($index + 1).'_'.$slide->stored_filename; $imageFilename = $prefix.'_'.($index + 1).'_'.basename($slide->stored_filename);
$destPath = $tempDir.'/'.$imageFilename; $destPath = $tempDir.'/'.$imageFilename;
copy($storedPath, $destPath); copy($storedPath, $destPath);
$imageFiles[$imageFilename] = file_get_contents($destPath); $imageFiles[$imageFilename] = file_get_contents($destPath);
$slideDataList[] = [ $slideDataList[] = [
'text' => '',
'media' => $imageFilename, 'media' => $imageFilename,
'format' => 'JPG', 'format' => 'JPG',
'label' => $slide->original_filename, 'label' => $slide->original_filename,

View file

@ -3,11 +3,13 @@
namespace App\Services; namespace App\Services;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use InvalidArgumentException; use InvalidArgumentException;
use ProPresenter\Parser\PresentationBundle;
use ProPresenter\Parser\ProBundleWriter;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
use RuntimeException; use RuntimeException;
use ZipArchive;
class ProBundleExportService class ProBundleExportService
{ {
@ -25,11 +27,53 @@ public function generateBundle(Service $service, string $blockType): string
->get(); ->get();
$groupName = ucfirst($blockType); $groupName = ucfirst($blockType);
return $this->buildBundleFromSlides($slides, $groupName);
}
public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
{
$agendaItem->loadMissing([
'slides',
'serviceSong.song.groups.slides',
'serviceSong.song.arrangements.arrangementGroups.group',
]);
$title = $agendaItem->title ?: 'Ablauf-Element';
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $agendaItem->serviceSong->song;
if ($song->groups()->count() === 0) {
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
}
$parserSong = (new ProExportService)->generateParserSong($song);
$proFilename = self::safeFilename($song->title).'.pro';
$bundle = new PresentationBundle($parserSong, $proFilename);
$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);
}
/** @param \Illuminate\Database\Eloquent\Collection<int, \App\Models\Slide> $slides */
private function buildBundleFromSlides($slides, string $groupName): string
{
$slideData = []; $slideData = [];
$mediaFiles = []; $mediaFiles = [];
foreach ($slides as $slide) { foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path('slides/'.$slide->stored_filename); $sourcePath = Storage::disk('public')->path($slide->stored_filename);
if (! file_exists($sourcePath)) { if (! file_exists($sourcePath)) {
continue; continue;
} }
@ -43,9 +87,9 @@ public function generateBundle(Service $service, string $blockType): string
$mediaFiles[$imageFilename] = $imageContent; $mediaFiles[$imageFilename] = $imageContent;
$slideData[] = [ $slideData[] = [
'text' => $slide->original_filename ?? '',
'media' => $imageFilename, 'media' => $imageFilename,
'format' => 'JPG', 'format' => 'JPG',
'label' => $slide->original_filename,
]; ];
} }
@ -65,37 +109,17 @@ public function generateBundle(Service $service, string $blockType): string
]; ];
$song = ProFileGenerator::generate($groupName, $groups, $arrangements); $song = ProFileGenerator::generate($groupName, $groups, $arrangements);
$protoBytes = $song->getPresentation()->serializeToString(); $proFilename = self::safeFilename($groupName).'.pro';
$bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle'; $bundle = new PresentationBundle($song, $proFilename, $mediaFiles);
$bundlePath = sys_get_temp_dir().'/'.uniqid('bundle-').'.probundle';
$zip = new ZipArchive; ProBundleWriter::write($bundle, $bundlePath);
$openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($openResult !== true) {
throw new InvalidArgumentException('Konnte .probundle nicht erstellen.');
}
$this->addStoredEntry($zip, 'data', $protoBytes);
foreach ($mediaFiles as $filename => $contents) {
$this->addStoredEntry($zip, $filename, $contents);
}
if (! $zip->close()) {
throw new RuntimeException('Fehler beim Finalisieren der .probundle Datei.');
}
return $bundlePath; return $bundlePath;
} }
private function addStoredEntry(ZipArchive $zip, string $entryName, string $contents): void private static function safeFilename(string $name): string
{ {
if (! $zip->addFromString($entryName, $contents)) { return preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name) ?: 'presentation';
throw new RuntimeException(sprintf('Fehler beim Hinzufügen von %s zur .probundle Datei.', $entryName));
}
if (! $zip->setCompressionName($entryName, ZipArchive::CM_STORE)) {
throw new RuntimeException(sprintf('Fehler beim Setzen der Kompression für %s.', $entryName));
}
} }
} }

View file

@ -10,19 +10,31 @@ class ProExportService
{ {
public function generateProFile(Song $song): string public function generateProFile(Song $song): string
{ {
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
$groups = $this->buildGroups($song);
$arrangements = $this->buildArrangements($song);
$ccli = $this->buildCcliMetadata($song);
$tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro'; $tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
ProFileGenerator::generateAndWrite($tempPath, $song->title, $groups, $arrangements, $ccli); ProFileGenerator::generateAndWrite(
$tempPath,
$song->title,
$this->buildGroups($song),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
return $tempPath; return $tempPath;
} }
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
{
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
return ProFileGenerator::generate(
$song->title,
$this->buildGroups($song),
$this->buildArrangements($song),
$this->buildCcliMetadata($song),
);
}
private function buildGroups(Song $song): array private function buildGroups(Song $song): array
{ {
$groups = []; $groups = [];

View file

@ -8,7 +8,7 @@
"repositories": [ "repositories": [
{ {
"type": "path", "type": "path",
"url": "../propresenter-work/php" "url": "../propresenter/php"
} }
], ],
"require": { "require": {

6
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "acd6c572df1b1867949b4c8a5061553e", "content-hash": "746ee5a4cf131b6b821d1abae2818edf",
"packages": [ "packages": [
{ {
"name": "5pm-hdh/churchtools-api", "name": "5pm-hdh/churchtools-api",
@ -3815,10 +3815,10 @@
}, },
{ {
"name": "propresenter/parser", "name": "propresenter/parser",
"version": "dev-propresenter-parser", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "../propresenter-work/php", "url": "../propresenter/php",
"reference": "43b6fa020bc05e5e9b58254ebcfbfb811f920ccf" "reference": "43b6fa020bc05e5e9b58254ebcfbfb811f920ccf"
}, },
"require": { "require": {

View file

@ -16,7 +16,7 @@ public function definition(): array
'type' => $this->faker->randomElement(['information', 'moderation', 'sermon']), 'type' => $this->faker->randomElement(['information', 'moderation', 'sermon']),
'service_id' => Service::factory(), 'service_id' => Service::factory(),
'original_filename' => $this->faker->word().'.jpg', 'original_filename' => $this->faker->word().'.jpg',
'stored_filename' => $this->faker->uuid().'.jpg', 'stored_filename' => 'slides/'.$this->faker->uuid().'.jpg',
'thumbnail_filename' => $this->faker->uuid().'_thumb.jpg', 'thumbnail_filename' => $this->faker->uuid().'_thumb.jpg',
'expire_date' => $this->faker->optional()->dateTimeBetween('now', '+12 months'), 'expire_date' => $this->faker->optional()->dateTimeBetween('now', '+12 months'),
'uploader_name' => $this->faker->name(), 'uploader_name' => $this->faker->name(),

View file

@ -14,6 +14,46 @@ const props = defineProps({
const emit = defineEmits(['slides-updated', 'scroll-to-info']) const emit = defineEmits(['slides-updated', 'scroll-to-info'])
const showUploader = ref(false) const showUploader = ref(false)
const downloading = ref(false)
async function downloadBundle() {
downloading.value = true
try {
const response = await fetch(
route('services.agenda-item.download', {
service: props.serviceId,
agendaItem: props.agendaItem.id,
}),
{
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
},
)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
alert(data.message || 'Fehler beim Herunterladen.')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'element.probundle'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
alert('Fehler beim Herunterladen.')
} finally {
downloading.value = false
}
}
// Preview state // Preview state
const previewSlide = ref(null) const previewSlide = ref(null)
@ -263,6 +303,25 @@ function scrollToInfo() {
<!-- Aktionen --> <!-- Aktionen -->
<td class="py-2.5 align-top"> <td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<!-- Download bundle -->
<button
v-if="agendaItem.slides?.length"
type="button"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-emerald-100 hover:text-emerald-600 disabled:opacity-50"
data-testid="agenda-item-download-bundle"
title="Als .probundle herunterladen"
:disabled="downloading"
@click="downloadBundle"
>
<svg v-if="downloading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<!-- Announcement row: scroll to info block instead of + button --> <!-- Announcement row: scroll to info block instead of + button -->
<button <button
v-if="agendaItem.is_announcement_position" v-if="agendaItem.is_announcement_position"

View file

@ -37,6 +37,7 @@ const expireDate = ref('')
const uploading = ref(false) const uploading = ref(false)
const uploadProgress = ref(0) const uploadProgress = ref(0)
const uploadError = ref('') const uploadError = ref('')
const uploadWarnings = ref([])
const uploadedCount = ref(0) const uploadedCount = ref(0)
const totalCount = ref(0) const totalCount = ref(0)
@ -64,6 +65,7 @@ function processFiles() {
if (files.value.length === 0 || uploading.value) return if (files.value.length === 0 || uploading.value) return
uploadError.value = '' uploadError.value = ''
uploadWarnings.value = []
totalCount.value = files.value.length totalCount.value = files.value.length
uploadedCount.value = 0 uploadedCount.value = 0
uploading.value = true uploading.value = true
@ -128,7 +130,10 @@ function uploadNextFile(index) {
uploadProgress.value = (index * perFileWeight) + (percent / 100 * perFileWeight) uploadProgress.value = (index * perFileWeight) + (percent / 100 * perFileWeight)
} }
}, },
}).then(() => { }).then((response) => {
if (response.data?.warnings?.length) {
uploadWarnings.value.push(...response.data.warnings)
}
uploadedCount.value = index + 1 uploadedCount.value = index + 1
uploadNextFile(index + 1) uploadNextFile(index + 1)
}).catch((error) => { }).catch((error) => {
@ -141,6 +146,10 @@ function uploadNextFile(index) {
function dismissError() { function dismissError() {
uploadError.value = '' uploadError.value = ''
} }
function dismissWarnings() {
uploadWarnings.value = []
}
</script> </script>
<template> <template>
@ -193,6 +202,43 @@ function dismissError() {
</div> </div>
</Transition> </Transition>
<!-- Dimension warnings -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"
>
<div
v-if="uploadWarnings.length > 0"
:class="[
'flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50/80 text-sm text-amber-800',
inline ? 'px-2 py-1.5 mb-2' : 'px-4 py-3 mb-4'
]"
data-testid="slide-uploader-warnings"
>
<svg class="h-4 w-4 shrink-0 mt-0.5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
<div class="flex-1">
<p v-for="(warning, i) in [...new Set(uploadWarnings)]" :key="i" class="text-xs leading-relaxed" :class="{ 'mt-1': i > 0 }">
{{ warning }}
</p>
</div>
<button
data-testid="slide-uploader-warnings-dismiss"
@click="dismissWarnings"
class="shrink-0 rounded-lg p-0.5 text-amber-500 transition hover:text-amber-700"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Transition>
<!-- Upload progress bar --> <!-- Upload progress bar -->
<Transition <Transition
enter-active-class="transition duration-300 ease-out" enter-active-class="transition duration-300 ease-out"

View file

@ -15,6 +15,7 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
const isMatched = computed(() => serviceSong.value?.song_id != null) const isMatched = computed(() => serviceSong.value?.song_id != null)
const useTranslation = ref(false) const useTranslation = ref(false)
const downloading = ref(false)
const toastMessage = ref('') const toastMessage = ref('')
const toastVariant = ref('info') const toastVariant = ref('info')
@ -123,6 +124,47 @@ function formatDateTime(value) {
minute: '2-digit', minute: '2-digit',
}) })
} }
async function downloadBundle() {
downloading.value = true
try {
const response = await fetch(
route('services.agenda-item.download', {
service: props.serviceId,
agendaItem: props.agendaItem.id,
}),
{
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
},
},
)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
showToast(data.message || 'Fehler beim Herunterladen.', 'error')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'song.probundle'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
showToast('Download gestartet.', 'success')
} catch {
showToast('Fehler beim Herunterladen.', 'error')
} finally {
downloading.value = false
}
}
</script> </script>
<template> <template>
@ -227,6 +269,25 @@ function formatDateTime(value) {
/> />
</label> </label>
<!-- Download bundle -->
<button
v-if="isMatched"
type="button"
class="flex h-7 w-7 items-center justify-center rounded text-gray-400 transition hover:bg-emerald-100 hover:text-emerald-600 disabled:opacity-50"
data-testid="song-download-bundle"
title="Als .probundle herunterladen"
:disabled="downloading"
@click="downloadBundle"
>
<svg v-if="downloading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<!-- Edit / assign song opens dialog for both matched and unmatched --> <!-- Edit / assign song opens dialog for both matched and unmatched -->
<button <button
type="button" type="button"

View file

@ -57,6 +57,7 @@
Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy'); Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download'); Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download');
Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle'); Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle');
Route::get('/services/{service}/agenda-items/{agendaItem}/download', [ServiceController::class, 'downloadAgendaItem'])->name('services.agenda-item.download');
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit'); Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate'); Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');

View file

@ -0,0 +1,232 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Tests\TestCase;
use ZipArchive;
final class AgendaItemDownloadTest extends TestCase
{
use RefreshDatabase;
public function test_agenda_item_mit_slides_liefert_probundle(): void
{
Storage::fake('public');
$user = User::factory()->create();
$service = Service::factory()->create();
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'sort_order' => 1,
'service_song_id' => null,
'is_before_event' => false,
]);
Storage::disk('public')->put('slides/predigt-1.jpg', 'fake-image-1');
Storage::disk('public')->put('slides/predigt-2.jpg', 'fake-image-2');
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'stored_filename' => 'slides/predigt-1.jpg',
'original_filename' => 'Predigt Folie 1.jpg',
'sort_order' => 0,
]);
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'stored_filename' => 'slides/predigt-2.jpg',
'original_filename' => 'Predigt Folie 2.jpg',
'sort_order' => 1,
]);
$response = $this->actingAs($user)->get(
route('services.agenda-item.download', [
'service' => $service,
'agendaItem' => $agendaItem,
])
);
$response->assertOk();
$response->assertHeader('content-type', 'application/zip');
$baseResponse = $response->baseResponse;
$this->assertInstanceOf(BinaryFileResponse::class, $baseResponse);
$copiedPath = sys_get_temp_dir().'/agenda-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive;
$this->assertTrue($zip->open($copiedPath) === true);
$this->assertSame(3, $zip->numFiles);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name !== false) {
$names[] = $name;
}
}
$proEntry = array_values(array_filter($names, fn (string $n) => str_ends_with($n, '.pro')));
$this->assertCount(1, $proEntry, '.probundle muss genau eine .pro Datei enthalten');
$this->assertContains('predigt-1.jpg', $names);
$this->assertContains('predigt-2.jpg', $names);
$this->assertSame('fake-image-1', $zip->getFromName('predigt-1.jpg'));
$this->assertSame('fake-image-2', $zip->getFromName('predigt-2.jpg'));
$zip->close();
@unlink($copiedPath);
}
public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
{
$user = User::factory()->create();
$service = Service::factory()->create();
$song = Song::factory()->create(['title' => 'Amazing Grace']);
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]);
SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Amazing Grace',
'cts_ccli_id' => '12345',
]);
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Amazing Grace',
'sort_order' => 1,
'service_song_id' => $serviceSong->id,
'is_before_event' => false,
]);
$response = $this->actingAs($user)->get(
route('services.agenda-item.download', [
'service' => $service,
'agendaItem' => $agendaItem,
])
);
$response->assertOk();
$response->assertHeader('content-type', 'application/zip');
$baseResponse = $response->baseResponse;
$this->assertInstanceOf(BinaryFileResponse::class, $baseResponse);
$copiedPath = sys_get_temp_dir().'/agenda-song-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive;
$this->assertTrue($zip->open($copiedPath) === true);
$this->assertSame(1, $zip->numFiles);
$this->assertTrue(str_ends_with($zip->getNameIndex(0), '.pro'), '.probundle muss eine .pro Datei enthalten');
$proContent = $zip->getFromName($zip->getNameIndex(0));
$this->assertNotFalse($proContent);
$this->assertGreaterThan(0, strlen($proContent));
$zip->close();
@unlink($copiedPath);
}
public function test_agenda_item_von_anderem_service_liefert_404(): void
{
$user = User::factory()->create();
$service = Service::factory()->create();
$otherService = Service::factory()->create();
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $otherService->id,
'title' => 'Fremd',
'sort_order' => 1,
'is_before_event' => false,
]);
$response = $this->actingAs($user)->get(
route('services.agenda-item.download', [
'service' => $service,
'agendaItem' => $agendaItem,
])
);
$response->assertNotFound();
}
public function test_agenda_item_ohne_slides_und_ohne_song_liefert_leeres_probundle(): void
{
$user = User::factory()->create();
$service = Service::factory()->create();
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Leer',
'sort_order' => 1,
'service_song_id' => null,
'is_before_event' => false,
]);
$response = $this->actingAs($user)->get(
route('services.agenda-item.download', [
'service' => $service,
'agendaItem' => $agendaItem,
])
);
$response->assertOk();
$baseResponse = $response->baseResponse;
$this->assertInstanceOf(BinaryFileResponse::class, $baseResponse);
$copiedPath = sys_get_temp_dir().'/agenda-empty-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive;
$this->assertTrue($zip->open($copiedPath) === true);
$this->assertSame(1, $zip->numFiles);
$this->assertTrue(str_ends_with($zip->getNameIndex(0), '.pro'), 'Einziger Eintrag muss .pro Datei sein');
$zip->close();
@unlink($copiedPath);
}
public function test_erfordert_authentifizierung(): void
{
$service = Service::factory()->create();
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'is_before_event' => false,
]);
$response = $this->get(
route('services.agenda-item.download', [
'service' => $service,
'agendaItem' => $agendaItem,
])
);
$response->assertRedirect(route('login'));
}
}

View file

@ -267,7 +267,7 @@
'service_agenda_item_id' => $agendaItem->id, 'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon', 'type' => 'sermon',
'original_filename' => 'predigt.jpg', 'original_filename' => 'predigt.jpg',
'stored_filename' => 'predigt_stored.jpg', 'stored_filename' => 'slides/predigt_stored.jpg',
'thumbnail_filename' => 'predigt_thumb.jpg', 'thumbnail_filename' => 'predigt_thumb.jpg',
'uploaded_at' => now(), 'uploaded_at' => now(),
'sort_order' => 1, 'sort_order' => 1,

View file

@ -43,6 +43,72 @@
expect($thumbHeight)->toBe(180); expect($thumbHeight)->toBe(180);
}); });
test('small image is upscaled with at most two black bars', function () {
$service = app(FileConversionService::class);
// 4:3 image → taller than 16:9 → pillarbox (left/right only)
$file = makePngUpload('small.png', 400, 300);
$result = $service->convertImage($file);
$mainPath = Storage::disk('public')->path($result['filename']);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
// Left/right edges must be black (pillarbox)
$left = imagecolorsforindex($image, imagecolorat($image, 0, 540));
$right = imagecolorsforindex($image, imagecolorat($image, 1919, 540));
expect($left['red'] + $left['green'] + $left['blue'])->toBeLessThan(20);
expect($right['red'] + $right['green'] + $right['blue'])->toBeLessThan(20);
// Top/bottom center must be image content (red), NOT black
$topCenter = imagecolorsforindex($image, imagecolorat($image, 960, 0));
$bottomCenter = imagecolorsforindex($image, imagecolorat($image, 960, 1079));
expect($topCenter['red'])->toBeGreaterThan(100);
expect($bottomCenter['red'])->toBeGreaterThan(100);
});
test('exact 16:9 image has no black bars', function () {
$service = app(FileConversionService::class);
$file = makePngUpload('exact.png', 1920, 1080);
$result = $service->convertImage($file);
$mainPath = Storage::disk('public')->path($result['filename']);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
// All corners must be image content (red), no black bars
$tl = imagecolorsforindex($image, imagecolorat($image, 0, 0));
$tr = imagecolorsforindex($image, imagecolorat($image, 1919, 0));
$bl = imagecolorsforindex($image, imagecolorat($image, 0, 1079));
$br = imagecolorsforindex($image, imagecolorat($image, 1919, 1079));
expect($tl['red'])->toBeGreaterThan(100);
expect($tr['red'])->toBeGreaterThan(100);
expect($bl['red'])->toBeGreaterThan(100);
expect($br['red'])->toBeGreaterThan(100);
});
test('small 16:9 image is upscaled without black bars', function () {
$service = app(FileConversionService::class);
$file = makePngUpload('small169.png', 320, 180);
$result = $service->convertImage($file);
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
// All corners must be image content (red), no black bars
$tl = imagecolorsforindex($image, imagecolorat($image, 0, 0));
$br = imagecolorsforindex($image, imagecolorat($image, 1919, 1079));
expect($tl['red'])->toBeGreaterThan(100);
expect($br['red'])->toBeGreaterThan(100);
});
test('portrait image gets pillarbox bars on left and right', function () { test('portrait image gets pillarbox bars on left and right', function () {
$service = app(FileConversionService::class); $service = app(FileConversionService::class);
$file = makePngUpload('portrait.png', 1080, 1920); $file = makePngUpload('portrait.png', 1080, 1920);

View file

@ -282,7 +282,7 @@ public function test_agenda_export_informationen_an_gematchter_position(): void
'type' => 'information', 'type' => 'information',
'service_id' => null, 'service_id' => null,
'original_filename' => 'info1.jpg', 'original_filename' => 'info1.jpg',
'stored_filename' => 'info1.jpg', 'stored_filename' => 'slides/info1.jpg',
'sort_order' => 1, 'sort_order' => 1,
]); ]);
@ -347,7 +347,7 @@ public function test_agenda_export_informationen_am_anfang_als_fallback(): void
'type' => 'information', 'type' => 'information',
'service_id' => null, 'service_id' => null,
'original_filename' => 'info_fallback.jpg', 'original_filename' => 'info_fallback.jpg',
'stored_filename' => 'info_fallback.jpg', 'stored_filename' => 'slides/info_fallback.jpg',
'sort_order' => 1, 'sort_order' => 1,
]); ]);
@ -542,7 +542,7 @@ public function test_agenda_export_mit_slides_auf_agenda_item(): void
'service_id' => $service->id, 'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id, 'service_agenda_item_id' => $sermonItem->id,
'original_filename' => 'predigt1.jpg', 'original_filename' => 'predigt1.jpg',
'stored_filename' => 'predigt1.jpg', 'stored_filename' => 'slides/predigt1.jpg',
'sort_order' => 1, 'sort_order' => 1,
]); ]);
@ -685,7 +685,7 @@ public function test_finalize_und_download_flow_mit_agenda_items(): void
'service_id' => $service->id, 'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id, 'service_agenda_item_id' => $sermonItem->id,
'original_filename' => 'sermon_slide1.jpg', 'original_filename' => 'sermon_slide1.jpg',
'stored_filename' => 'sermon_slide1.jpg', 'stored_filename' => 'slides/sermon_slide1.jpg',
'sort_order' => 1, 'sort_order' => 1,
]); ]);

View file

@ -19,7 +19,7 @@ final class ProBundleExportTest extends TestCase
use MakesHttpRequests; use MakesHttpRequests;
use RefreshDatabase; use RefreshDatabase;
public function test_probundle_enthaelt_data_und_bilder(): void public function test_probundle_enthaelt_pro_datei_und_bilder(): void
{ {
Storage::fake('public'); Storage::fake('public');
@ -38,7 +38,7 @@ public function test_probundle_enthaelt_data_und_bilder(): void
Slide::factory()->create([ Slide::factory()->create([
'service_id' => $service->id, 'service_id' => $service->id,
'type' => 'information', 'type' => 'information',
'stored_filename' => $filename, 'stored_filename' => 'slides/'.$filename,
'original_filename' => 'Original '.$filename, 'original_filename' => 'Original '.$filename,
'sort_order' => $index, 'sort_order' => $index,
]); ]);
@ -74,22 +74,20 @@ public function test_probundle_enthaelt_data_und_bilder(): void
} }
} }
$this->assertContains('data', $names, '.probundle muss einen data-Eintrag (Protobuf) enthalten'); $proEntry = array_values(array_filter($names, fn (string $n) => str_ends_with($n, '.pro')));
$this->assertCount(1, $proEntry, '.probundle muss genau eine .pro Datei enthalten');
$this->assertContains('info-1.jpg', $names); $this->assertContains('info-1.jpg', $names);
$this->assertContains('info-2.jpg', $names); $this->assertContains('info-2.jpg', $names);
$this->assertContains('info-3.jpg', $names); $this->assertContains('info-3.jpg', $names);
// Verify media files contain actual content, not file paths
foreach ($filenames as $index => $filename) { foreach ($filenames as $index => $filename) {
$content = $zip->getFromName($filename); $content = $zip->getFromName($filename);
$this->assertSame('fake-image-content-'.$index, $content, "Bildinhalt von {$filename} muss korrekt sein"); $this->assertSame('fake-image-content-'.$index, $content, "Bildinhalt von {$filename} muss korrekt sein");
} }
// Verify data entry is non-empty protobuf (not a file path) $proContent = $zip->getFromName($proEntry[0]);
$dataContent = $zip->getFromName('data'); $this->assertNotFalse($proContent, '.pro Eintrag muss existieren');
$this->assertNotFalse($dataContent, 'data-Eintrag muss existieren'); $this->assertGreaterThan(0, strlen($proContent), '.pro Eintrag darf nicht leer sein');
$this->assertGreaterThan(0, strlen($dataContent), 'data-Eintrag darf nicht leer sein');
$this->assertFalse(str_starts_with($dataContent, '/'), 'data-Eintrag darf kein Dateipfad sein');
$zip->close(); $zip->close();
@unlink($copiedPath); @unlink($copiedPath);
@ -108,7 +106,7 @@ public function test_ungueltiger_block_type_liefert_422(): void
$response->assertStatus(422); $response->assertStatus(422);
} }
public function test_probundle_ohne_slides_enthaelt_nur_data(): void public function test_probundle_ohne_slides_enthaelt_nur_pro_datei(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$service = Service::factory()->create(); $service = Service::factory()->create();
@ -133,7 +131,7 @@ public function test_probundle_ohne_slides_enthaelt_nur_data(): void
$this->assertTrue($openResult === true); $this->assertTrue($openResult === true);
$this->assertSame(1, $zip->numFiles); $this->assertSame(1, $zip->numFiles);
$this->assertSame('data', $zip->getNameIndex(0)); $this->assertTrue(str_ends_with($zip->getNameIndex(0), '.pro'), 'Einziger Eintrag muss .pro Datei sein');
$zip->close(); $zip->close();
@unlink($copiedPath); @unlink($copiedPath);

View file

@ -319,6 +319,68 @@
expect($slide->fresh()->expire_date)->toBeNull(); expect($slide->fresh()->expire_date)->toBeNull();
}); });
/*
|--------------------------------------------------------------------------
| Upload warnings
|--------------------------------------------------------------------------
*/
test('upload small image returns dimension warnings', function () {
$service = Service::factory()->create();
$file = makePngUploadForSlide('small.png', 400, 300);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
'service_id' => $service->id,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
$response->assertJsonStructure(['warnings']);
$warnings = $response->json('warnings');
expect($warnings)->toBeArray();
expect($warnings)->toHaveCount(2);
expect($warnings[0])->toContain('Seitenverhältnis');
expect($warnings[1])->toContain('hochskaliert');
});
test('upload exact 1920x1080 image returns no warnings', function () {
$service = Service::factory()->create();
$file = makePngUploadForSlide('perfect.png', 1920, 1080);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
'service_id' => $service->id,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
$response->assertJsonMissing(['warnings']);
});
test('upload wrong aspect ratio returns ratio warning only', function () {
$service = Service::factory()->create();
// 2000x1200 is larger than 1920x1080 but not 16:9
$file = makePngUploadForSlide('wide.png', 2000, 1200);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
'service_id' => $service->id,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
$warnings = $response->json('warnings');
expect($warnings)->toBeArray();
expect($warnings)->toHaveCount(1);
expect($warnings[0])->toContain('Seitenverhältnis');
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Helpers | Helpers