feat(songs): implement .pro file import with SongDB mapping
This commit is contained in:
parent
70d8bcb4d2
commit
77d47f4b73
|
|
@ -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();
|
||||
|
|
|
|||
209
app/Services/ProImportService.php
Normal file
209
app/Services/ProImportService.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use ProPresenter\Parser\ProFileReader;
|
||||
use ProPresenter\Parser\Song as ProSong;
|
||||
use ZipArchive;
|
||||
|
||||
class ProImportService
|
||||
{
|
||||
/** @return Song[] */
|
||||
public function import(UploadedFile $file): array
|
||||
{
|
||||
$extension = strtolower($file->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);
|
||||
}
|
||||
}
|
||||
118
tests/Feature/ProFileImportTest.php
Normal file
118
tests/Feature/ProFileImportTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ProFileImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function testProFile(): UploadedFile
|
||||
{
|
||||
$sourcePath = base_path('../propresenter-work/ref/Test.pro');
|
||||
|
||||
return new UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
||||
}
|
||||
|
||||
public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): void
|
||||
{
|
||||
$user = User::factory()->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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue