From ca7160068e1a5751ddcefab9cf43f1f9ad0ff39d Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 2 Mar 2026 12:22:48 +0100 Subject: [PATCH] feat(songs): implement .pro file download/export from SongDB --- .../ProParserNotImplementedException.php | 21 ---- app/Http/Controllers/ProFileController.php | 16 ++- app/Services/ProExportService.php | 84 +++++++++++++++ tests/Feature/ProFileExportTest.php | 101 ++++++++++++++++++ tests/Feature/ProPlaceholderTest.php | 40 ------- 5 files changed, 198 insertions(+), 64 deletions(-) delete mode 100644 app/Exceptions/ProParserNotImplementedException.php create mode 100644 app/Services/ProExportService.php create mode 100644 tests/Feature/ProFileExportTest.php delete mode 100644 tests/Feature/ProPlaceholderTest.php diff --git a/app/Exceptions/ProParserNotImplementedException.php b/app/Exceptions/ProParserNotImplementedException.php deleted file mode 100644 index 27e04ab..0000000 --- a/app/Exceptions/ProParserNotImplementedException.php +++ /dev/null @@ -1,21 +0,0 @@ -json([ - 'message' => $this->message, - 'error' => 'ProParserNotImplemented', - ], 501); - } -} diff --git a/app/Http/Controllers/ProFileController.php b/app/Http/Controllers/ProFileController.php index f26fdad..8c930c1 100644 --- a/app/Http/Controllers/ProFileController.php +++ b/app/Http/Controllers/ProFileController.php @@ -2,11 +2,12 @@ namespace App\Http\Controllers; -use App\Exceptions\ProParserNotImplementedException; use App\Models\Song; +use App\Services\ProExportService; use App\Services\ProImportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class ProFileController extends Controller { @@ -46,8 +47,17 @@ public function importPro(Request $request): JsonResponse } } - public function downloadPro(Song $song): JsonResponse + public function downloadPro(Song $song): BinaryFileResponse { - throw new ProParserNotImplementedException(); + if ($song->groups()->count() === 0) { + abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.'); + } + + $exportService = new ProExportService(); + $tempPath = $exportService->generateProFile($song); + + $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title) . '.pro'; + + return response()->download($tempPath, $filename)->deleteFileAfterSend(true); } } diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php new file mode 100644 index 0000000..c47813a --- /dev/null +++ b/app/Services/ProExportService.php @@ -0,0 +1,84 @@ +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'; + + ProFileGenerator::generateAndWrite($tempPath, $song->title, $groups, $arrangements, $ccli); + + return $tempPath; + } + + private function buildGroups(Song $song): array + { + $groups = []; + + foreach ($song->groups->sortBy('order') as $group) { + $slides = []; + + foreach ($group->slides->sortBy('order') as $slide) { + $slideData = ['text' => $slide->text_content ?? '']; + + if ($slide->text_content_translated) { + $slideData['translation'] = $slide->text_content_translated; + } + + $slides[] = $slideData; + } + + $groups[] = [ + 'name' => $group->name, + 'color' => ProImportService::hexToRgba($group->color), + 'slides' => $slides, + ]; + } + + return $groups; + } + + private function buildArrangements(Song $song): array + { + $arrangements = []; + $groupIdToName = $song->groups->pluck('name', 'id')->toArray(); + + foreach ($song->arrangements as $arrangement) { + $groupNames = $arrangement->arrangementGroups + ->sortBy('order') + ->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null) + ->filter() + ->values() + ->toArray(); + + $arrangements[] = [ + 'name' => $arrangement->name, + 'groupNames' => $groupNames, + ]; + } + + return $arrangements; + } + + private function buildCcliMetadata(Song $song): array + { + return array_filter([ + 'author' => $song->author, + 'song_title' => $song->title, + 'copyright_year' => $song->copyright_year, + 'publisher' => $song->publisher, + 'song_number' => $song->ccli_id ? (int) $song->ccli_id : null, + ]); + } +} diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php new file mode 100644 index 0000000..34b8dcf --- /dev/null +++ b/tests/Feature/ProFileExportTest.php @@ -0,0 +1,101 @@ + 'Export Test Song', + 'ccli_id' => '54321', + 'author' => 'Test Author', + 'copyright_text' => 'Test Publisher', + 'copyright_year' => 2024, + 'publisher' => 'Test Publisher', + ]); + + $verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]); + $verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']); + $verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']); + + $chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]); + $chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']); + + $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); + $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]); + $arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]); + $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]); + + return $song; + } + + public function test_download_pro_gibt_datei_zurueck(): void + { + $user = User::factory()->create(); + $song = $this->createSongWithContent(); + + $response = $this->actingAs($user)->get("/api/songs/{$song->id}/download-pro"); + + $response->assertOk(); + $response->assertHeader('content-disposition'); + $this->assertStringContains('Export Test Song.pro', $response->headers->get('content-disposition')); + } + + public function test_download_pro_song_ohne_gruppen_gibt_422(): void + { + $user = User::factory()->create(); + $song = Song::factory()->create(); + + $response = $this->actingAs($user)->get("/api/songs/{$song->id}/download-pro"); + + $response->assertStatus(422); + } + + public function test_download_pro_erfordert_authentifizierung(): void + { + $song = Song::factory()->create(); + + $response = $this->getJson("/api/songs/{$song->id}/download-pro"); + + $response->assertUnauthorized(); + } + + public function test_download_pro_roundtrip_import_export(): void + { + $user = User::factory()->create(); + + $sourcePath = base_path('../propresenter-work/ref/Test.pro'); + $file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true); + + $importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]); + $importResponse->assertOk(); + + $songId = $importResponse->json('songs.0.id'); + $song = Song::find($songId); + + $this->assertNotNull($song); + $this->assertGreaterThan(0, $song->groups()->count()); + + $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); + $exportResponse->assertOk(); + } + + private function assertStringContains(string $needle, ?string $haystack): void + { + $this->assertNotNull($haystack); + $this->assertTrue( + str_contains($haystack, $needle), + "Failed asserting that '{$haystack}' contains '{$needle}'" + ); + } +} diff --git a/tests/Feature/ProPlaceholderTest.php b/tests/Feature/ProPlaceholderTest.php deleted file mode 100644 index 66f8452..0000000 --- a/tests/Feature/ProPlaceholderTest.php +++ /dev/null @@ -1,40 +0,0 @@ -user = User::factory()->create(); - }); - - describe('GET /api/songs/{song}/download-pro', function () { - it('returns 501 Not Implemented with German error message', function () { - $song = Song::factory()->create(); - - $response = $this->actingAs($this->user) - ->get("/api/songs/{$song->id}/download-pro"); - - $response->assertStatus(501); - $response->assertJson([ - 'message' => 'Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation.', - 'error' => 'ProParserNotImplemented', - ]); - }); - - it('requires authentication', function () { - $song = Song::factory()->create(); - - $response = $this->getJson("/api/songs/{$song->id}/download-pro"); - - $response->assertStatus(401); - }); - - it('returns 404 for non-existent song', function () { - $response = $this->actingAs($this->user) - ->get('/api/songs/99999/download-pro'); - - $response->assertStatus(404); - }); - }); -});