pp-planer/app/Http/Controllers/SlideController.php
Thorsten Bus 0e3c647cfc 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)
2026-03-30 10:29:37 +02:00

295 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Slide;
use App\Services\FileConversionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
class SlideController extends Controller
{
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'];
private const POWERPOINT_EXTENSIONS = ['ppt', 'pptx'];
public function store(Request $request, FileConversionService $conversionService): JsonResponse
{
$validated = $request->validate([
'file' => ['required', 'file', 'max:51200'],
'type' => ['required', Rule::in(['information', 'moderation', 'sermon', 'agenda_item'])],
'service_id' => ['nullable', 'exists:services,id'],
'service_agenda_item_id' => ['nullable', 'integer', 'exists:service_agenda_items,id'],
'expire_date' => ['nullable', 'date'],
]);
// moderation, sermon, and agenda_item slides require a service_id
if (in_array($validated['type'], ['moderation', 'sermon', 'agenda_item']) && empty($validated['service_id'])) {
return response()->json([
'message' => 'Moderations- und Predigtfolien benötigen einen Gottesdienst.',
'errors' => ['service_id' => ['Gottesdienst ist erforderlich.']],
], 422);
}
/** @var UploadedFile $file */
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension());
// Validate file extension
$allowedExtensions = [...self::IMAGE_EXTENSIONS, ...self::POWERPOINT_EXTENSIONS, 'zip'];
if (! in_array($extension, $allowedExtensions, true)) {
return response()->json([
'message' => 'Dateityp nicht erlaubt.',
'errors' => ['file' => ['Nur PNG, JPG, PPT, PPTX und ZIP-Dateien sind erlaubt.']],
], 422);
}
$uploaderName = $request->user()?->name ?? 'Unbekannt';
$serviceId = $validated['service_id'] ?? null;
$serviceAgendaItemId = $validated['service_agenda_item_id'] ?? null;
$type = $validated['type'];
$expireDate = $validated['expire_date'] ?? null;
// Handle PowerPoint files — dispatch async job
if (in_array($extension, self::POWERPOINT_EXTENSIONS, true)) {
return $this->handlePowerPoint($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate, $serviceAgendaItemId);
}
// Handle ZIP files — extract and process
if ($extension === 'zip') {
return $this->handleZip($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate, $serviceAgendaItemId);
}
// Handle images — convert synchronously
return $this->handleImage($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate, $serviceAgendaItemId);
}
public function destroy(Slide $slide): JsonResponse
{
$slide->delete();
return response()->json([
'success' => true,
'message' => 'Folie wurde gelöscht.',
]);
}
public function destroyBulk(Request $request): JsonResponse
{
$validated = $request->validate([
'type' => ['required', Rule::in(['information', 'moderation', 'sermon', 'agenda_item'])],
'service_id' => ['nullable', 'exists:services,id'],
]);
$query = Slide::where('type', $validated['type']);
if ($validated['service_id']) {
$query->where('service_id', $validated['service_id']);
} else {
// Information slides without service_id (global)
$query->whereNull('service_id');
}
$count = $query->count();
$query->delete(); // soft-delete via SoftDeletes trait
return response()->json([
'success' => true,
'message' => $count.' Folien wurden gelöscht.',
'count' => $count,
]);
}
public function reorder(Request $request): JsonResponse
{
$validated = $request->validate([
'slides' => ['required', 'array', 'min:1'],
'slides.*.id' => ['required', 'integer', 'exists:slides,id'],
'slides.*.sort_order' => ['required', 'integer', 'min:0'],
]);
foreach ($validated['slides'] as $item) {
Slide::where('id', $item['id'])->update(['sort_order' => $item['sort_order']]);
}
return response()->json([
'success' => true,
'message' => 'Reihenfolge wurde aktualisiert.',
]);
}
public function updateExpireDate(Request $request, Slide $slide): JsonResponse
{
if ($slide->type !== 'information') {
return response()->json([
'message' => 'Ablaufdatum kann nur für Informationsfolien gesetzt werden.',
'errors' => ['type' => ['Nur Informationsfolien haben ein Ablaufdatum.']],
], 422);
}
$validated = $request->validate([
'expire_date' => ['nullable', 'date'],
]);
$slide->update(['expire_date' => $validated['expire_date']]);
return response()->json([
'success' => true,
'slide' => $slide->fresh(),
]);
}
private function nextSortOrder(string $type, ?int $serviceId): int
{
return (int) Slide::where('type', $type)
->when($serviceId, fn ($q) => $q->where('service_id', $serviceId), fn ($q) => $q->whereNull('service_id'))
->max('sort_order') + 1;
}
private function handleImage(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
?int $serviceAgendaItemId = null,
): JsonResponse {
try {
$result = $conversionService->convertImage($file);
$slide = Slide::create([
'type' => $type,
'service_id' => $serviceId,
'service_agenda_item_id' => $serviceAgendaItemId,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
'sort_order' => $this->nextSortOrder($type, $serviceId),
]);
$response = [
'success' => true,
'slide' => $slide,
];
if (! empty($result['warnings'])) {
$response['warnings'] = $result['warnings'];
}
return response()->json($response);
} catch (InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
private function handlePowerPoint(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
?int $serviceAgendaItemId = null,
): JsonResponse {
// Store file persistently so the job can access it
$storedPath = $file->store('temp/ppt', 'local');
try {
$jobId = $conversionService->convertPowerPoint(
storage_path('app/'.$storedPath)
);
return response()->json([
'success' => true,
'job_id' => $jobId,
'message' => 'PowerPoint wird verarbeitet...',
'meta' => [
'type' => $type,
'service_id' => $serviceId,
'service_agenda_item_id' => $serviceAgendaItemId,
'uploader_name' => $uploaderName,
'expire_date' => $expireDate,
'original_filename' => $file->getClientOriginalName(),
],
]);
} catch (InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
private function handleZip(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
?int $serviceAgendaItemId = null,
): JsonResponse {
try {
$results = $conversionService->processZip($file);
$slides = [];
$allWarnings = [];
$sortOrder = $this->nextSortOrder($type, $serviceId);
foreach ($results as $result) {
// Skip PPT job results (they are handled asynchronously)
if (isset($result['job_id'])) {
continue;
}
$slides[] = Slide::create([
'type' => $type,
'service_id' => $serviceId,
'service_agenda_item_id' => $serviceAgendaItemId,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
'sort_order' => $sortOrder++,
]);
if (! empty($result['warnings'])) {
array_push($allWarnings, ...$result['warnings']);
}
}
$response = [
'success' => true,
'slides' => $slides,
'count' => count($slides),
];
if (! empty($allWarnings)) {
$response['warnings'] = array_values(array_unique($allWarnings));
}
return response()->json($response);
} catch (InvalidArgumentException $e) {
Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
}