From 77d47f4b73c36f4dc28176a6e0b8e0d6d516cf33 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 2 Mar 2026 12:21:01 +0100 Subject: [PATCH] feat(songs): implement .pro file import with SongDB mapping --- app/Http/Controllers/ProFileController.php | 42 ++++- app/Services/ProImportService.php | 209 +++++++++++++++++++++ tests/Feature/ProFileImportTest.php | 118 ++++++++++++ tests/Feature/ProPlaceholderTest.php | 23 --- 4 files changed, 360 insertions(+), 32 deletions(-) create mode 100644 app/Services/ProImportService.php create mode 100644 tests/Feature/ProFileImportTest.php diff --git a/app/Http/Controllers/ProFileController.php b/app/Http/Controllers/ProFileController.php index 8aeddb3..f26fdad 100644 --- a/app/Http/Controllers/ProFileController.php +++ b/app/Http/Controllers/ProFileController.php @@ -4,24 +4,48 @@ use App\Exceptions\ProParserNotImplementedException; use App\Models\Song; +use App\Services\ProImportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ProFileController extends Controller { - /** - * Upload and import .pro file(s). - * Placeholder: throws NotImplementedException until parser spec is finalized. - */ public function importPro(Request $request): JsonResponse { - throw new ProParserNotImplementedException(); + $request->validate([ + 'file' => ['required', 'file', 'max:51200'], + ]); + + $file = $request->file('file'); + $extension = strtolower($file->getClientOriginalExtension()); + + if (! in_array($extension, ['pro', 'zip'])) { + return response()->json([ + 'message' => 'Nur .pro und .zip Dateien sind erlaubt.', + ], 422); + } + + try { + $service = new ProImportService(); + $songs = $service->import($file); + + return response()->json([ + 'message' => count($songs) === 1 + ? "Song \"{$songs[0]->title}\" erfolgreich importiert." + : count($songs) . ' Songs erfolgreich importiert.', + 'songs' => collect($songs)->map(fn (Song $song) => [ + 'id' => $song->id, + 'title' => $song->title, + 'ccli_id' => $song->ccli_id, + ]), + ]); + } catch (\InvalidArgumentException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } } - /** - * Download .pro file for a song. - * Placeholder: throws NotImplementedException until parser spec is finalized. - */ public function downloadPro(Song $song): JsonResponse { throw new ProParserNotImplementedException(); diff --git a/app/Services/ProImportService.php b/app/Services/ProImportService.php new file mode 100644 index 0000000..411ec39 --- /dev/null +++ b/app/Services/ProImportService.php @@ -0,0 +1,209 @@ +getClientOriginalExtension()); + + if ($extension === 'zip') { + return $this->importZip($file); + } + + if ($extension === 'pro') { + return [$this->importProFile($file->getRealPath())]; + } + + throw new \InvalidArgumentException('Nur .pro und .zip Dateien sind erlaubt.'); + } + + /** @return Song[] */ + private function importZip(UploadedFile $file): array + { + $zip = new ZipArchive(); + + if ($zip->open($file->getRealPath()) !== true) { + throw new \RuntimeException('ZIP-Datei konnte nicht geöffnet werden.'); + } + + $tempDir = sys_get_temp_dir() . '/pro-import-' . uniqid(); + mkdir($tempDir, 0755, true); + + $songs = []; + + try { + $zip->extractTo($tempDir); + $zip->close(); + + $proFiles = glob($tempDir . '/*.pro') ?: []; + $proFilesNested = glob($tempDir . '/**/*.pro') ?: []; + $allProFiles = array_unique(array_merge($proFiles, $proFilesNested)); + + if (empty($allProFiles)) { + throw new \RuntimeException('Keine .pro Dateien im ZIP-Archiv gefunden.'); + } + + foreach ($allProFiles as $proPath) { + $songs[] = $this->importProFile($proPath); + } + } finally { + $this->deleteDirectory($tempDir); + } + + return $songs; + } + + private function importProFile(string $filePath): Song + { + $proSong = ProFileReader::read($filePath); + + return DB::transaction(function () use ($proSong) { + return $this->upsertSong($proSong); + }); + } + + private function upsertSong(ProSong $proSong): Song + { + $ccliId = $proSong->getCcliSongNumber(); + + $songData = [ + 'title' => $proSong->getName(), + 'author' => $proSong->getCcliAuthor() ?: null, + 'copyright_text' => $proSong->getCcliPublisher() ?: null, + 'copyright_year' => $proSong->getCcliCopyrightYear() ?: null, + 'publisher' => $proSong->getCcliPublisher() ?: null, + ]; + + if ($ccliId) { + $song = Song::withTrashed()->where('ccli_id', (string) $ccliId)->first(); + + if ($song) { + if ($song->trashed()) { + $song->restore(); + } + $song->update($songData); + } else { + $song = Song::create(array_merge($songData, ['ccli_id' => (string) $ccliId])); + } + } else { + $song = Song::create(array_merge($songData, ['ccli_id' => null])); + } + + $song->arrangements()->each(function (SongArrangement $arr) { + $arr->arrangementGroups()->delete(); + }); + $song->arrangements()->delete(); + $song->groups()->each(function (SongGroup $group) { + $group->slides()->delete(); + }); + $song->groups()->delete(); + + $hasTranslation = false; + $groupMap = []; + + foreach ($proSong->getGroups() as $position => $proGroup) { + $color = $proGroup->getColor(); + $hexColor = $color ? self::rgbaToHex($color) : '#808080'; + + $songGroup = $song->groups()->create([ + 'name' => $proGroup->getName(), + 'color' => $hexColor, + 'order' => $position, + ]); + + $groupMap[$proGroup->getName()] = $songGroup; + + foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) { + $translatedText = null; + + if ($proSlide->hasTranslation()) { + $translatedText = $proSlide->getTranslation()->getPlainText(); + $hasTranslation = true; + } + + $songGroup->slides()->create([ + 'order' => $slidePosition, + 'text_content' => $proSlide->getPlainText(), + 'text_content_translated' => $translatedText, + ]); + } + } + + $song->update(['has_translation' => $hasTranslation]); + + foreach ($proSong->getArrangements() as $proArrangement) { + $arrangement = $song->arrangements()->create([ + 'name' => $proArrangement->getName(), + 'is_default' => strtolower($proArrangement->getName()) === 'normal', + ]); + + $groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement); + + foreach ($groupsInArrangement as $order => $proGroup) { + $songGroup = $groupMap[$proGroup->getName()] ?? null; + + if ($songGroup) { + SongArrangementGroup::create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $songGroup->id, + 'order' => $order, + ]); + } + } + } + + return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']); + } + + public static function rgbaToHex(array $rgba): string + { + $r = (int) round(($rgba['r'] ?? 0) * 255); + $g = (int) round(($rgba['g'] ?? 0) * 255); + $b = (int) round(($rgba['b'] ?? 0) * 255); + + return sprintf('#%02X%02X%02X', $r, $g, $b); + } + + public static function hexToRgba(string $hex): array + { + $hex = ltrim($hex, '#'); + + $r = hexdec(substr($hex, 0, 2)) / 255; + $g = hexdec(substr($hex, 2, 2)) / 255; + $b = hexdec(substr($hex, 4, 2)) / 255; + + return [round($r, 4), round($g, 4), round($b, 4), 1.0]; + } + + private function deleteDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $dir . '/' . $item; + is_dir($path) ? $this->deleteDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/Feature/ProFileImportTest.php b/tests/Feature/ProFileImportTest.php new file mode 100644 index 0000000..800a0c6 --- /dev/null +++ b/tests/Feature/ProFileImportTest.php @@ -0,0 +1,118 @@ +create(); + + $response = $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $response->assertOk(); + $response->assertJsonPath('songs.0.title', 'Test'); + + $song = Song::where('title', 'Test')->first(); + $this->assertNotNull($song); + $this->assertSame(4, $song->groups()->count()); + $this->assertSame(5, $song->groups()->withCount('slides')->get()->sum('slides_count')); + $this->assertSame(2, $song->arrangements()->count()); + $this->assertTrue($song->has_translation); + } + + public function test_import_pro_ohne_ccli_erstellt_neuen_song(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $this->assertSame(1, Song::count()); + + $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $this->assertSame(2, Song::count()); + } + + public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void + { + $user = User::factory()->create(); + + $existingSong = Song::create([ + 'title' => 'Old Title', + 'ccli_id' => '999', + ]); + $existingSong->groups()->create(['name' => 'Old Group', 'color' => '#FF0000', 'order' => 0]); + + $this->assertSame(1, $existingSong->groups()->count()); + + $existingSong->update(['ccli_id' => '999']); + $this->assertSame(1, Song::count()); + + $response = $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $response->assertOk(); + $this->assertSame(2, Song::count()); + } + + public function test_import_pro_lehnt_ungueltige_datei_ab(): void + { + $user = User::factory()->create(); + + $invalidFile = UploadedFile::fake()->create('test.txt', 100); + + $response = $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $invalidFile, + ]); + + $response->assertStatus(422); + } + + public function test_import_pro_erfordert_authentifizierung(): void + { + $response = $this->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $response->assertUnauthorized(); + } + + public function test_import_pro_erstellt_arrangement_gruppen(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->postJson(route('api.songs.import-pro'), [ + 'file' => $this->testProFile(), + ]); + + $song = Song::where('title', 'Test')->first(); + $normalArrangement = $song->arrangements()->where('name', 'normal')->first(); + + $this->assertNotNull($normalArrangement); + $this->assertTrue($normalArrangement->is_default); + $this->assertSame(5, $normalArrangement->arrangementGroups()->count()); + } +} diff --git a/tests/Feature/ProPlaceholderTest.php b/tests/Feature/ProPlaceholderTest.php index a1fe22a..66f8452 100644 --- a/tests/Feature/ProPlaceholderTest.php +++ b/tests/Feature/ProPlaceholderTest.php @@ -8,29 +8,6 @@ $this->user = User::factory()->create(); }); - describe('POST /api/songs/import-pro', function () { - it('returns 501 Not Implemented with German error message', function () { - $response = $this->actingAs($this->user) - ->post('/api/songs/import-pro', [ - 'file' => 'test.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 () { - $response = $this->postJson('/api/songs/import-pro', [ - 'file' => 'test.pro', - ]); - - $response->assertStatus(401); - }); - }); - describe('GET /api/songs/{song}/download-pro', function () { it('returns 501 Not Implemented with German error message', function () { $song = Song::factory()->create();