pp-planer/app/Http/Controllers/CcliPasteController.php
Thorsten Bus 35d3298251 feat(ccli): add CcliPasteController endpoints
- POST /api/ccli/preview: parse-only endpoint (no DB writes)
- POST /api/songs/import-from-ccli-paste: 3 modes (create / pair-with-song / assign-to-service-song)
- GET /songs/import-from-ccli-paste: Inertia page with base64 bookmarklet prefill
- Routes guarded by auth:sanctum + throttle:30,1 (API); auth + web stack (web)
- Maps DuplicateCcliSongException to 409 with existing_song_id and edit_url
- Pest tests (10 cases, 63 assertions): preview, all 3 import modes, 409 dup, 422 errors, unauth, prefill happy/error, login redirect
2026-05-11 09:23:11 +02:00

178 lines
6.4 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\CcliImportService;
use App\Services\CcliPasteParser;
use App\Services\CcliTranslationPairingService;
use App\Services\SongMatchingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class CcliPasteController extends Controller
{
public function __construct(
private readonly CcliPasteParser $parser,
private readonly CcliImportService $importService,
private readonly CcliTranslationPairingService $pairingService,
private readonly SongMatchingService $matchingService,
) {}
/**
* POST /api/ccli/preview
* Parse raw text and return DTO as JSON. No DB writes.
*/
public function preview(Request $request): JsonResponse
{
$validated = $request->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,
]);
}
}