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:
parent
63f40f8364
commit
0e3c647cfc
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "../propresenter-work/php"
|
"url": "../propresenter/php"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
|
|
|
||||||
6
composer.lock
generated
6
composer.lock
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
232
tests/Feature/AgendaItemDownloadTest.php
Normal file
232
tests/Feature/AgendaItemDownloadTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue