diff --git a/.sisyphus/notepads/ccli-songselect-import/learnings.md b/.sisyphus/notepads/ccli-songselect-import/learnings.md index 151f6a6..ba7d11b 100644 --- a/.sisyphus/notepads/ccli-songselect-import/learnings.md +++ b/.sisyphus/notepads/ccli-songselect-import/learnings.md @@ -99,3 +99,12 @@ ### 2026-05-10 CCLI Translation Pairing - `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence. - Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1` ↔ `Verse 1` and `Refrain` ↔ `Chorus` work across languages. - Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section. + +### 2026-05-11 CcliPasteController (T10) +- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan. +- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php. +- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`. +- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg. +- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest. +- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`). +- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message. diff --git a/app/Http/Controllers/CcliPasteController.php b/app/Http/Controllers/CcliPasteController.php new file mode 100644 index 0000000..88cd9a1 --- /dev/null +++ b/app/Http/Controllers/CcliPasteController.php @@ -0,0 +1,177 @@ +validate([ + 'raw_text' => ['required', 'string', 'max:200000'], + ]); + + try { + $parsed = $this->parser->parse($validated['raw_text']); + } catch (InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + + return response()->json([ + 'title' => $parsed->title, + 'author' => $parsed->author, + 'ccliId' => $parsed->ccliId, + 'year' => $parsed->year, + 'copyrightText' => $parsed->copyrightText, + 'sections' => array_map(fn ($s) => [ + 'label' => $s->label, + 'kind' => $s->kind, + 'number' => $s->number, + 'modifier' => $s->modifier, + 'lines' => $s->lines, + 'hasTranslation' => $s->linesTranslated !== null, + ], $parsed->sections), + ]); + } + + /** + * POST /api/songs/import-from-ccli-paste + * Import a CCLI paste in one of 3 modes. + */ + public function importPaste(Request $request): JsonResponse + { + $validated = $request->validate([ + 'raw_text' => ['required', 'string', 'max:200000'], + 'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])], + 'target_id' => ['required_unless:mode,create', 'nullable', 'integer'], + 'source_url' => ['nullable', 'string', 'max:500'], + ]); + + $rawText = $validated['raw_text']; + $mode = $validated['mode']; + $targetId = $validated['target_id'] ?? null; + $sourceUrl = $validated['source_url'] ?? null; + + try { + if ($mode === 'create') { + $result = $this->importService->import($rawText, $sourceUrl); + + return response()->json([ + 'song_id' => $result['song']->id, + 'status' => $result['status'], + 'warnings' => $result['warnings'], + ], 201); + } + + if ($mode === 'pair-with-song') { + $localSong = Song::findOrFail($targetId); + $result = $this->pairingService->pair($localSong, $rawText); + + session()->flash('ccli_prefilled', $result['distributed_text']); + + return response()->json([ + 'song_id' => $localSong->id, + 'mapping' => $result['mapping'], + 'unmatched_labels' => $result['unmatched_labels'], + 'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true', + ]); + } + + if ($mode === 'assign-to-service-song') { + $result = $this->importService->import($rawText, $sourceUrl); + $song = $result['song']; + + $serviceSong = ServiceSong::findOrFail($targetId); + $this->matchingService->manualAssign($serviceSong, $song); + + return response()->json([ + 'song_id' => $song->id, + 'service_song_id' => $serviceSong->id, + 'status' => $result['status'], + ], 201); + } + } catch (DuplicateCcliSongException $e) { + return response()->json([ + 'message' => $e->getMessage(), + 'existing_song_id' => $e->existingSongId, + 'edit_url' => route('songs.index').'#song-'.$e->existingSongId, + ], 409); + } catch (InvalidArgumentException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return response()->json(['message' => 'Unbekannter Modus'], 422); + } + + /** + * GET /songs/import-from-ccli-paste + * Render the Inertia page for bookmarklet redirect. + */ + public function showImportPage(Request $request): InertiaResponse + { + $prefill = $request->query('prefill'); + $prefilledText = null; + $prefilledMetadata = null; + $prefillError = null; + + if ($prefill !== null && is_string($prefill)) { + try { + $decoded = base64_decode($prefill, strict: true); + if ($decoded === false) { + throw new InvalidArgumentException('Ungültige Kodierung'); + } + $payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! is_array($payload)) { + throw new InvalidArgumentException('Ungültiges Payload-Format'); + } + + $prefilledText = $payload['rawText'] ?? null; + $prefilledMetadata = [ + 'title' => $payload['title'] ?? null, + 'author' => $payload['author'] ?? null, + 'ccliId' => $payload['ccliId'] ?? null, + 'sourceUrl' => $payload['sourceUrl'] ?? null, + ]; + } catch (Throwable) { + $prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.'; + } + } + + return Inertia::render('Songs/ImportFromCcliPaste', [ + 'prefilledText' => $prefilledText, + 'prefilledMetadata' => $prefilledMetadata, + 'prefillError' => $prefillError, + ]); + } +} diff --git a/routes/api.php b/routes/api.php index 08caf14..2721338 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('api.songs.download-pro'); + + // CCLI Paste Import (manuelles Einfügen oder Bookmarklet) + Route::middleware('throttle:30,1')->group(function () { + Route::post('/ccli/preview', [CcliPasteController::class, 'preview']) + ->name('api.ccli.preview'); + Route::post('/songs/import-from-ccli-paste', [CcliPasteController::class, 'importPaste']) + ->name('api.ccli.import'); + }); }); diff --git a/routes/web.php b/routes/web.php index 3a8b2d4..8c7dff1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\ApiLogController; use App\Http\Controllers\AuthController; use App\Http\Controllers\BookmarkletController; +use App\Http\Controllers\CcliPasteController; use App\Http\Controllers\LabelImportController; use App\Http\Controllers\MacroAssignmentController; use App\Http\Controllers\MacroImportController; @@ -72,6 +73,9 @@ return Inertia::render('Songs/Index'); })->name('songs.index'); + Route::get('/songs/import-from-ccli-paste', [CcliPasteController::class, 'showImportPage']) + ->name('songs.import-from-ccli-paste'); + Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index'); Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body'); diff --git a/tests/Feature/CcliPasteControllerTest.php b/tests/Feature/CcliPasteControllerTest.php new file mode 100644 index 0000000..e5383ac --- /dev/null +++ b/tests/Feature/CcliPasteControllerTest.php @@ -0,0 +1,171 @@ +create(); + $content = ccliPasteControllerFixture('english-only-multi-verse.txt'); + + $songCountBefore = Song::count(); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.preview'), ['raw_text' => $content]); + + $response->assertStatus(200); + $response->assertJsonStructure(['title', 'author', 'ccliId', 'year', 'copyrightText', 'sections']); + expect($response->json('title'))->toBe('Test Song 1'); + expect($response->json('ccliId'))->toBe('9999001'); + expect(Song::count())->toBe($songCountBefore); +}); + +test('importPaste create mode persists song and returns 201', function () { + $user = User::factory()->create(); + $content = ccliPasteControllerFixture('english-only-multi-verse.txt'); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.import'), [ + 'raw_text' => $content, + 'mode' => 'create', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['song_id', 'status', 'warnings']); + expect($response->json('status'))->toBe('created'); + expect(Song::find($response->json('song_id')))->not->toBeNull(); +}); + +test('importPaste returns 409 on duplicate ccli_id with existing_song_id', function () { + $user = User::factory()->create(); + $content = ccliPasteControllerFixture('english-only-multi-verse.txt'); + + $first = $this->actingAs($user) + ->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']); + $existingId = $first->json('song_id'); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']); + + $response->assertStatus(409); + $response->assertJsonStructure(['message', 'existing_song_id', 'edit_url']); + expect($response->json('existing_song_id'))->toBe($existingId); + expect($response->json('message'))->toContain('existiert bereits'); +}); + +test('importPaste returns 422 on malformed paste with German error', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.import'), [ + 'raw_text' => 'This has no section labels at all', + 'mode' => 'create', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message']); + expect($response->json('message'))->not->toBeEmpty(); +}); + +test('importPaste returns 422 on empty raw_text', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.import'), [ + 'raw_text' => '', + 'mode' => 'create', + ]); + + $response->assertStatus(422); +}); + +test('importPaste assign-to-service-song imports and assigns song to service song', function () { + $user = User::factory()->create(); + $content = ccliPasteControllerFixture('english-only-multi-verse.txt'); + + $service = Service::factory()->create(); + $serviceSong = ServiceSong::factory()->create([ + 'service_id' => $service->id, + 'song_id' => null, + 'song_arrangement_id' => null, + 'matched_at' => null, + ]); + + $response = $this->actingAs($user) + ->postJson(route('api.ccli.import'), [ + 'raw_text' => $content, + 'mode' => 'assign-to-service-song', + 'target_id' => $serviceSong->id, + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['song_id', 'service_song_id', 'status']); + + $serviceSong->refresh(); + expect($serviceSong->song_id)->toBe($response->json('song_id')); + expect($serviceSong->matched_at)->not->toBeNull(); +}); + +test('importPaste rejects unauthenticated requests with 401', function () { + $response = $this->postJson(route('api.ccli.import'), [ + 'raw_text' => 'test', + 'mode' => 'create', + ]); + + $response->assertUnauthorized(); +}); + +test('showImportPage renders Inertia page with prefilled data from valid base64', function () { + $user = User::factory()->create(); + $this->withoutVite(); + + $payload = json_encode([ + 'title' => 'Amazing Grace', + 'author' => 'John Newton', + 'ccliId' => '4760', + 'sourceUrl' => 'https://songselect.ccli.com/Songs/4760', + 'rawText' => "Amazing Grace\nJohn Newton\n\nVerse 1\nAmazing grace how sweet the sound\n\n© 1779\nCCLI # 4760", + ]); + $encoded = base64_encode($payload); + + $response = $this->actingAs($user) + ->get(route('songs.import-from-ccli-paste', ['prefill' => $encoded])); + + $response->assertStatus(200); + $response->assertInertia( + fn ($page) => $page->component('Songs/ImportFromCcliPaste', false) + ->has('prefilledText') + ->has('prefilledMetadata') + ->where('prefilledMetadata.title', 'Amazing Grace') + ->where('prefilledMetadata.ccliId', '4760') + ); +}); + +test('showImportPage renders with error for invalid base64', function () { + $user = User::factory()->create(); + $this->withoutVite(); + + $response = $this->actingAs($user) + ->get(route('songs.import-from-ccli-paste', ['prefill' => 'NOT_VALID_BASE64!!!'])); + + $response->assertStatus(200); + $response->assertInertia( + fn ($page) => $page->component('Songs/ImportFromCcliPaste', false) + ->where('prefillError', fn ($v) => $v !== null) + ); +}); + +test('showImportPage redirects unauthenticated to login', function () { + $response = $this->get(route('songs.import-from-ccli-paste')); + + $response->assertRedirect(route('login')); +});