feat(songs): implement .pro file download/export from SongDB
This commit is contained in:
parent
77d47f4b73
commit
ca7160068e
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class ProParserNotImplementedException extends Exception
|
|
||||||
{
|
|
||||||
public function __construct(string $message = 'Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation.')
|
|
||||||
{
|
|
||||||
parent::__construct($message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'message' => $this->message,
|
|
||||||
'error' => 'ProParserNotImplemented',
|
|
||||||
], 501);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Exceptions\ProParserNotImplementedException;
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
|
use App\Services\ProExportService;
|
||||||
use App\Services\ProImportService;
|
use App\Services\ProImportService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
class ProFileController extends Controller
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
app/Services/ProExportService.php
Normal file
84
app/Services/ProExportService.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use ProPresenter\Parser\ProFileGenerator;
|
||||||
|
|
||||||
|
class ProExportService
|
||||||
|
{
|
||||||
|
public function generateProFile(Song $song): string
|
||||||
|
{
|
||||||
|
$song->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
tests/Feature/ProFileExportTest.php
Normal file
101
tests/Feature/ProFileExportTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongGroup;
|
||||||
|
use App\Models\SongSlide;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class ProFileExportTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function createSongWithContent(): Song
|
||||||
|
{
|
||||||
|
$song = Song::create([
|
||||||
|
'title' => '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}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Song;
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
describe('Pro File Placeholder Endpoints', function () {
|
|
||||||
beforeEach(function () {
|
|
||||||
$this->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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue