- 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)
295 lines
10 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|