From 5b35afb31db0c8195c8e676db80bfee96eedb72c Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 2 Mar 2026 22:18:33 +0100 Subject: [PATCH] feat(export): add probundle export for service slide blocks - Create ProBundleExportService with generateBundle() method - Generate flat ZIP with .pro file + image files at root level - Add downloadBundle() method to ServiceController - Add services.download-bundle route - Add .probundle download buttons to Information, Moderation, Sermon blocks - Add 3 tests verifying ZIP structure and validation - All tests pass (206/206, 1129 assertions) --- app/Http/Controllers/ServiceController.php | 68 +++++++-- app/Services/ProBundleExportService.php | 117 ++++++++++++++++ .../js/Components/Blocks/InformationBlock.vue | 130 ++++++++++++------ .../js/Components/Blocks/ModerationBlock.vue | 88 ++++++++++-- .../js/Components/Blocks/SermonBlock.vue | 88 ++++++++++-- routes/web.php | 1 + tests/Feature/ProBundleExportTest.php | 129 +++++++++++++++++ 7 files changed, 548 insertions(+), 73 deletions(-) create mode 100644 app/Services/ProBundleExportService.php create mode 100644 tests/Feature/ProBundleExportTest.php diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 25a68a9..c285498 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -3,14 +3,17 @@ namespace App\Http\Controllers; use App\Models\Service; -use App\Models\Song; use App\Models\Slide; +use App\Models\Song; +use App\Services\ProBundleExportService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; -use Symfony\Component\HttpFoundation\BinaryFileResponse; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class ServiceController extends Controller { @@ -41,13 +44,21 @@ public function index(): Response 'info_slides_count' => Slide::query() ->selectRaw('COUNT(*)') ->where('slides.type', 'information') + ->whereNull('slides.deleted_at') ->where(function ($query) { $query ->whereNull('slides.service_id') ->orWhereColumn('slides.service_id', 'services.id'); }) - ->whereNotNull('slides.expire_date') - ->whereColumn('slides.expire_date', '>=', 'services.date'), + ->where(function ($query) { + $query->whereNull('slides.expire_date') + ->orWhereColumn('slides.expire_date', '>=', 'services.date'); + }) + ->when( + ! $archived, + fn ($q) => $q + ->whereColumn(DB::raw('DATE(slides.uploaded_at)'), '<=', 'services.date') + ), ]) ->get() ->map(fn (Service $service) => [ @@ -107,11 +118,14 @@ public function edit(Service $service): Response ->when( $service->date, fn ($query) => $query - ->where(function ($q) use ($service) { - $q->whereNull('expire_date') - ->orWhereDate('expire_date', '>=', $service->date); - }) - ->whereDate('uploaded_at', '<=', $service->date) + ->where(function ($q) use ($service) { + $q->whereNull('expire_date') + ->orWhereDate('expire_date', '>=', $service->date); + }) + ->when( + $service->date->isFuture() || $service->date->isToday(), + fn ($q) => $q->whereDate('uploaded_at', '<=', $service->date) + ) ) ->orderByDesc('uploaded_at') ->get(); @@ -126,6 +140,14 @@ public function edit(Service $service): Response ->sortByDesc('uploaded_at') ->values(); + $prevService = Service::where('date', '<', $service->date->toDateString()) + ->orderByDesc('date') + ->first(['id', 'title', 'date']); + + $nextService = Service::where('date', '>', $service->date->toDateString()) + ->orderBy('date') + ->first(['id', 'title', 'date']); + return Inertia::render('Services/Edit', [ 'service' => [ 'id' => $service->id, @@ -188,6 +210,16 @@ public function edit(Service $service): Response 'moderationSlides' => $moderationSlides, 'sermonSlides' => $sermonSlides, 'songsCatalog' => $songsCatalog, + 'prevService' => $prevService ? [ + 'id' => $prevService->id, + 'title' => $prevService->title, + 'date' => $prevService->date?->toDateString(), + ] : null, + 'nextService' => $nextService ? [ + 'id' => $nextService->id, + 'title' => $nextService->title, + 'date' => $nextService->date?->toDateString(), + ] : null, ]); } @@ -257,4 +289,22 @@ public function download(Service $service): JsonResponse|BinaryFileResponse return response()->json(['message' => $e->getMessage()], 422); } } + + public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse + { + $request->merge(['blockType' => $blockType]); + $request->validate([ + 'blockType' => 'required|in:information,moderation,sermon', + ]); + + $bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType); + + return response() + ->download( + $bundlePath, + "{$service->id}_{$blockType}.probundle", + ['Content-Type' => 'application/zip'] + ) + ->deleteFileAfterSend(true); + } } diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php new file mode 100644 index 0000000..336ad17 --- /dev/null +++ b/app/Services/ProBundleExportService.php @@ -0,0 +1,117 @@ +slides() + ->where('type', $blockType) + ->orderBy('sort_order') + ->get(); + + $tempDir = sys_get_temp_dir().'/probundle-export-'.uniqid(); + mkdir($tempDir, 0755, true); + + $groupName = ucfirst($blockType); + $slideData = []; + $copiedImagePaths = []; + + foreach ($slides as $slide) { + $sourcePath = Storage::disk('public')->path('slides/'.$slide->stored_filename); + if (! file_exists($sourcePath)) { + continue; + } + + $imageFilename = basename($slide->stored_filename); + $tempImagePath = $tempDir.'/'.$imageFilename; + copy($sourcePath, $tempImagePath); + $copiedImagePaths[] = $tempImagePath; + + $slideData[] = [ + 'text' => $slide->original_filename ?? '', + 'media' => $imageFilename, + 'format' => 'JPG', + ]; + } + + $groups = [ + [ + 'name' => $groupName, + 'color' => [0, 0, 0, 1], + 'slides' => $slideData, + ], + ]; + + $arrangements = [ + [ + 'name' => 'normal', + 'groupNames' => [$groupName], + ], + ]; + + $proFilePath = $tempDir.'/'.$blockType.'.pro'; + ProFileGenerator::generateAndWrite($proFilePath, $groupName, $groups, $arrangements); + + $bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle'; + + $zip = new ZipArchive(); + $openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + if ($openResult !== true) { + $this->deleteDirectory($tempDir); + throw new InvalidArgumentException('Konnte .probundle nicht erstellen.'); + } + + $zip->addFile($proFilePath, basename($proFilePath)); + + foreach ($copiedImagePaths as $imagePath) { + $zip->addFile($imagePath, basename($imagePath)); + } + + $zip->close(); + + $this->deleteDirectory($tempDir); + + return $bundlePath; + } + + private function deleteDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $entries = scandir($dir); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir.'/'.$entry; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } +} diff --git a/resources/js/Components/Blocks/InformationBlock.vue b/resources/js/Components/Blocks/InformationBlock.vue index 0e02111..5fbd212 100644 --- a/resources/js/Components/Blocks/InformationBlock.vue +++ b/resources/js/Components/Blocks/InformationBlock.vue @@ -1,8 +1,10 @@ + + + + + diff --git a/resources/js/Components/Blocks/ModerationBlock.vue b/resources/js/Components/Blocks/ModerationBlock.vue index 4bb387c..ab6b570 100644 --- a/resources/js/Components/Blocks/ModerationBlock.vue +++ b/resources/js/Components/Blocks/ModerationBlock.vue @@ -1,7 +1,10 @@ + + + + + diff --git a/resources/js/Components/Blocks/SermonBlock.vue b/resources/js/Components/Blocks/SermonBlock.vue index 13fdc14..1cb2bf3 100644 --- a/resources/js/Components/Blocks/SermonBlock.vue +++ b/resources/js/Components/Blocks/SermonBlock.vue @@ -1,7 +1,10 @@ + + + + + diff --git a/routes/web.php b/routes/web.php index 21b8db2..22e1946 100644 --- a/routes/web.php +++ b/routes/web.php @@ -56,6 +56,7 @@ Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen'); 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-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle'); Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit'); Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate'); diff --git a/tests/Feature/ProBundleExportTest.php b/tests/Feature/ProBundleExportTest.php new file mode 100644 index 0000000..eb09f8b --- /dev/null +++ b/tests/Feature/ProBundleExportTest.php @@ -0,0 +1,129 @@ +create(); + $service = Service::factory()->create(); + + $filenames = [ + 'info-1.jpg', + 'info-2.jpg', + 'info-3.jpg', + ]; + + foreach ($filenames as $index => $filename) { + Storage::disk('public')->put('slides/'.$filename, 'fake-image-content-'.$index); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => 'information', + 'stored_filename' => $filename, + 'original_filename' => 'Original '.$filename, + 'sort_order' => $index, + ]); + } + + $response = $this->actingAs($user)->get(route('services.download-bundle', [ + 'service' => $service, + 'blockType' => 'information', + ])); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/zip'); + + $baseResponse = $response->baseResponse; + if (! $baseResponse instanceof BinaryFileResponse) { + $this->fail('Es wurde keine Dateiantwort zurückgegeben.'); + } + + $copiedPath = sys_get_temp_dir().'/probundle-test-'.uniqid().'.probundle'; + copy($baseResponse->getFile()->getPathname(), $copiedPath); + + $zip = new ZipArchive(); + $openResult = $zip->open($copiedPath); + + $this->assertTrue($openResult === true); + $this->assertSame(4, $zip->numFiles); + + $names = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name !== false) { + $names[] = $name; + } + } + $zip->close(); + + @unlink($copiedPath); + + $this->assertContains('information.pro', $names); + $this->assertContains('info-1.jpg', $names); + $this->assertContains('info-2.jpg', $names); + $this->assertContains('info-3.jpg', $names); + } + + public function test_ungueltiger_block_type_liefert_422(): void + { + $user = User::factory()->create(); + $service = Service::factory()->create(); + + $response = $this->actingAs($user)->getJson(route('services.download-bundle', [ + 'service' => $service, + 'blockType' => 'ungueltig', + ])); + + $response->assertStatus(422); + } + + public function test_probundle_ohne_slides_enthaelt_nur_pro_datei(): void + { + $user = User::factory()->create(); + $service = Service::factory()->create(); + + $response = $this->actingAs($user)->get(route('services.download-bundle', [ + 'service' => $service, + 'blockType' => 'sermon', + ])); + + $response->assertOk(); + + $baseResponse = $response->baseResponse; + if (! $baseResponse instanceof BinaryFileResponse) { + $this->fail('Es wurde keine Dateiantwort zurückgegeben.'); + } + + $copiedPath = sys_get_temp_dir().'/probundle-empty-test-'.uniqid().'.probundle'; + copy($baseResponse->getFile()->getPathname(), $copiedPath); + + $zip = new ZipArchive(); + $openResult = $zip->open($copiedPath); + + $this->assertTrue($openResult === true); + $this->assertSame(1, $zip->numFiles); + $this->assertSame('sermon.pro', $zip->getNameIndex(0)); + $zip->close(); + + @unlink($copiedPath); + } +}