feat(ccli): add CcliTranslationPairingService

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Thorsten Bus 2026-05-10 19:02:10 +02:00
parent 091e00f255
commit cd44d6289c
9 changed files with 337 additions and 15 deletions

View file

@ -0,0 +1,17 @@
Task 8 evidence: German local labels pair with English CCLI labels.
Command run:
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
Relevant passing test:
✓ pairs German local labels with English CCLI labels via normalization
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
- Local "Strophe 1" normalizes to canonical "verse 1" and matches CCLI "Verse 1".
- Local "Refrain" normalizes to canonical "chorus" and matches CCLI "Chorus".
- unmatched_labels is empty.
- mapping has three entries.
Full targeted result:
Tests: 5 passed (20 assertions)
Duration: 0.46s

View file

@ -0,0 +1,18 @@
Task 8 evidence: unmatched local sections are returned for UI review.
Command run:
ddev exec php artisan test --filter=CcliTranslationPairingServiceTest
Relevant passing test:
✓ returns unmatched_labels for sections not in CCLI
Observed assertions in tests/Feature/CcliTranslationPairingServiceTest.php:
- Local arrangement contains Verse 1, Chorus, Bridge.
- CCLI paste contains Verse 1 and Chorus only.
- result['unmatched_labels'] contains "Bridge".
- mapping still has all three local labels.
- Bridge mapping has ccli_label = null and empty distributed line placeholders.
Full targeted result:
Tests: 5 passed (20 assertions)
Duration: 0.46s

View file

@ -29,3 +29,7 @@ ### Translation Pairing Label Direction
### T7: Global Label Slide Replacement Caveat ### T7: Global Label Slide Replacement Caveat
- The requested CCLI import pattern deletes `songSlides()` on the resolved global `Label` before recreating slides. - The requested CCLI import pattern deletes `songSlides()` on the resolved global `Label` before recreating slides.
- Because labels are shared globally, a later import using the same canonical label name replaces that label's slide text globally; this intentionally matches the task spec and existing `ProImportService` pattern, but remains a design caveat for future per-song slide ownership work. - Because labels are shared globally, a later import using the same canonical label name replaces that label's slide text globally; this intentionally matches the task spec and existing `ProImportService` pattern, but remains a design caveat for future per-song slide ownership work.
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.

View file

@ -94,3 +94,8 @@ ### 2026-05-10 CcliImportService Implementation
### 2026-05-10 CCLI Parser Review Fixes ### 2026-05-10 CCLI Parser Review Fixes
- CCLI SongSelect metadata can appear as `CCLI Song #`, `CCLI-Nr.` or `CCLI-Liednummer`; extraction must ignore `CCLI License/Lizenz` numbers. - CCLI SongSelect metadata can appear as `CCLI Song #`, `CCLI-Nr.` or `CCLI-Liednummer`; extraction must ignore `CCLI License/Lizenz` numbers.
- Parsed section `kind` is canonicalized via `CcliLabels::normalizeLabelName()`, while the original pasted label remains available in `label`; translation pairing still compares raw label kinds internally. - Parsed section `kind` is canonicalized via `CcliLabels::normalizeLabelName()`, while the original pasted label remains available in `label`; translation pairing still compares raw label kinds internally.
### 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.

View file

@ -0,0 +1,148 @@
<?php
namespace App\Services;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Services\DTO\ParsedCcliSection;
use App\Support\CcliLabels;
use Illuminate\Support\Collection;
final class CcliTranslationPairingService
{
public function __construct(
private readonly CcliPasteParser $parser,
) {}
/**
* Pair a CCLI paste as translation for an existing local song.
* Returns mapping for UI review (does NOT save to DB).
*
* @return array{
* song: Song,
* mapping: array<int, array{local_label: string, ccli_label: string|null, distributed_lines: string[]}>,
* unmatched_labels: string[],
* distributed_text: string
* }
*/
public function pair(Song $localSong, string $ccliRawText, string $arrangementName = 'normal'): array
{
$parsed = $this->parser->parse($ccliRawText);
$localSong->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
$arrangement = $this->findArrangement($localSong, $arrangementName);
if ($arrangement === null) {
return [
'song' => $localSong,
'mapping' => [],
'unmatched_labels' => [],
'distributed_text' => '',
];
}
$ccliByCanonical = $this->sectionsByCanonicalLabel($parsed->sections);
$mapping = [];
$unmatchedLabels = [];
$allDistributedLines = [];
foreach ($arrangement->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
$localCanonical = $this->canonicalLabel($label->name, null);
$matchedSection = $ccliByCanonical[$localCanonical] ?? null;
$slides = $label->songSlides->sortBy('order')->values();
if ($matchedSection === null) {
$unmatchedLabels[] = $label->name;
$distributedLines = array_fill(0, max($slides->count(), 1), '');
} else {
$distributedLines = $this->distributeLines(
$matchedSection->linesTranslated ?? $matchedSection->lines,
$slides,
);
}
$allDistributedLines = array_merge($allDistributedLines, $distributedLines);
$mapping[] = [
'local_label' => $label->name,
'ccli_label' => $matchedSection?->label,
'distributed_lines' => $distributedLines,
];
}
return [
'song' => $localSong,
'mapping' => $mapping,
'unmatched_labels' => $unmatchedLabels,
'distributed_text' => implode("\n", $allDistributedLines),
];
}
private function findArrangement(Song $localSong, string $arrangementName): ?SongArrangement
{
return $localSong->arrangements->where('name', $arrangementName)->first()
?? $localSong->arrangements->where('is_default', true)->first()
?? $localSong->arrangements->first();
}
/**
* @param ParsedCcliSection[] $sections
* @return array<string, ParsedCcliSection>
*/
private function sectionsByCanonicalLabel(array $sections): array
{
$byCanonical = [];
foreach ($sections as $section) {
$canonical = $this->canonicalLabel($section->kind, $section->number);
$byCanonical[$canonical] ??= $section;
}
return $byCanonical;
}
private function canonicalLabel(string $kind, ?string $number): string
{
$label = trim($kind.' '.($number ?? ''));
return mb_strtolower(CcliLabels::normalizeLabelName($label));
}
/**
* Distribute CCLI lines into local slide slots, preserving each local slide line count.
*
* @param string[] $lines
* @param Collection<int, mixed> $slides
* @return string[]
*/
private function distributeLines(array $lines, Collection $slides): array
{
if ($slides->isEmpty()) {
return $lines;
}
$distributed = [];
$offset = 0;
$lastSlideIndex = $slides->count() - 1;
foreach ($slides as $index => $slide) {
$lineCount = max(count(explode("\n", $slide->text_content ?? '')), 1);
$chunk = array_slice($lines, $offset, $lineCount);
$offset += $lineCount;
if ($index === $lastSlideIndex && $offset < count($lines)) {
$chunk = array_merge($chunk, array_slice($lines, $offset));
}
$distributed[] = implode("\n", $chunk);
}
return $distributed;
}
}

View file

@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class () extends Migration { return new class extends Migration
{
public function up(): void public function up(): void
{ {
Schema::table('songs', function (Blueprint $table): void { Schema::table('songs', function (Blueprint $table): void {

View file

@ -5,7 +5,7 @@
use App\Services\DTO\ParsedCcliSong; use App\Services\DTO\ParsedCcliSong;
test('CcliPasteParser can be instantiated with no arguments', function (): void { test('CcliPasteParser can be instantiated with no arguments', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
expect($parser)->toBeInstanceOf(CcliPasteParser::class); expect($parser)->toBeInstanceOf(CcliPasteParser::class);
}); });
@ -26,7 +26,7 @@
}); });
test('CcliPasteParser::parse returns ParsedCcliSong DTO', function (): void { test('CcliPasteParser::parse returns ParsedCcliSong DTO', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nSome text"); $result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nSome text");

View file

@ -15,7 +15,7 @@ function ccliFixtureContent(string $filename): string
} }
test('each fixture parses into a valid ParsedCcliSong DTO', function (): void { test('each fixture parses into a valid ParsedCcliSong DTO', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
foreach (glob(base_path('tests/fixtures/ccli/*.txt')) as $path) { foreach (glob(base_path('tests/fixtures/ccli/*.txt')) as $path) {
$filename = basename($path); $filename = basename($path);
@ -34,7 +34,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('english-only-multi-verse.txt parses 4+ sections without translation', function (): void { test('english-only-multi-verse.txt parses 4+ sections without translation', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('english-only-multi-verse.txt')); $result = $parser->parse(ccliFixtureContent('english-only-multi-verse.txt'));
expect(count($result->sections))->toBeGreaterThanOrEqual(4); expect(count($result->sections))->toBeGreaterThanOrEqual(4);
@ -51,7 +51,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('english-german-side-by-side.txt extracts both languages per section', function (): void { test('english-german-side-by-side.txt extracts both languages per section', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('english-german-side-by-side.txt')); $result = $parser->parse(ccliFixtureContent('english-german-side-by-side.txt'));
$translatedSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->linesTranslated !== null); $translatedSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->linesTranslated !== null);
@ -63,7 +63,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('german-only.txt detects German labels and normalizes section kind', function (): void { test('german-only.txt detects German labels and normalizes section kind', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('german-only.txt')); $result = $parser->parse(ccliFixtureContent('german-only.txt'));
$labels = array_map(fn (ParsedCcliSection $section): string => $section->label, $result->sections); $labels = array_map(fn (ParsedCcliSection $section): string => $section->label, $result->sections);
@ -75,7 +75,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('common CCLI metadata formats extract the song ID but not license numbers', function (): void { test('common CCLI metadata formats extract the song ID but not license numbers', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nLine\n\nCCLI Song # 1234567\nCCLI License # 111222"); $result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nLine\n\nCCLI Song # 1234567\nCCLI License # 111222");
expect($result->ccliId)->toBe('1234567'); expect($result->ccliId)->toBe('1234567');
@ -85,7 +85,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('repeat-marker.txt preserves modifier in section DTO', function (): void { test('repeat-marker.txt preserves modifier in section DTO', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('repeat-marker.txt')); $result = $parser->parse(ccliFixtureContent('repeat-marker.txt'));
$repeatSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->modifier !== null); $repeatSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->modifier !== null);
@ -93,7 +93,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('umlauts.txt preserves Unicode characters', function (): void { test('umlauts.txt preserves Unicode characters', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('umlauts.txt')); $result = $parser->parse(ccliFixtureContent('umlauts.txt'));
$allText = $result->title; $allText = $result->title;
@ -105,7 +105,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('missing-copyright.txt returns null copyrightText', function (): void { test('missing-copyright.txt returns null copyrightText', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('missing-copyright.txt')); $result = $parser->parse(ccliFixtureContent('missing-copyright.txt'));
expect($result->ccliId)->not->toBeNull('CCLI ID should still be extracted'); expect($result->ccliId)->not->toBeNull('CCLI ID should still be extracted');
@ -114,7 +114,7 @@ function ccliFixtureContent(string $filename): string
}); });
test('5-verses.txt handles 5 verse sections correctly', function (): void { test('5-verses.txt handles 5 verse sections correctly', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
$result = $parser->parse(ccliFixtureContent('5-verses.txt')); $result = $parser->parse(ccliFixtureContent('5-verses.txt'));
$verseSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => in_array(mb_strtolower($section->kind), ['verse', 'strophe'], true)); $verseSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => in_array(mb_strtolower($section->kind), ['verse', 'strophe'], true));
@ -122,19 +122,19 @@ function ccliFixtureContent(string $filename): string
}); });
test('parse throws InvalidArgumentException on empty input', function (): void { test('parse throws InvalidArgumentException on empty input', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class); expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
}); });
test('parse throws InvalidArgumentException on text with no section labels', function (): void { test('parse throws InvalidArgumentException on text with no section labels', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class); expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class);
}); });
test('parse error messages are in German', function (): void { test('parse error messages are in German', function (): void {
$parser = new CcliPasteParser(); $parser = new CcliPasteParser;
try { try {
$parser->parse(''); $parser->parse('');

View file

@ -0,0 +1,129 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Services\CcliTranslationPairingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeLocalSongForCcliPairing(array $labelConfig): Song
{
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'normal',
'is_default' => true,
]);
foreach ($labelConfig as $order => $config) {
['label_name' => $labelName, 'slide_count' => $slideCount] = $config;
$label = Label::firstOrCreate(
['name' => $labelName],
['color' => '#3B82F6'],
);
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => $order + 1,
]);
for ($i = 0; $i < $slideCount; $i++) {
SongSlide::create([
'label_id' => $label->id,
'order' => $i + 1,
'text_content' => "Original line $i for $labelName",
]);
}
}
return $song;
}
test('pairs matching English labels with CCLI sections', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Verse 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nCCLI line 1\nCCLI line 2\n\nChorus\nCCLI chorus\n\nVerse 2\nCCLI v2 line1\nCCLI v2 line2\n\n© 2024 Test\nCCLI # 9999001";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('All labels should match')
->and($result['mapping'])->toHaveCount(3)
->and($result['distributed_text'])->not->toBeEmpty();
foreach ($result['mapping'] as $entry) {
expect($entry['ccli_label'])->not->toBeNull("Label {$entry['local_label']} should have a CCLI match");
}
});
test('pairs German local labels with English CCLI labels via normalization', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Strophe 1', 'slide_count' => 2],
['label_name' => 'Refrain', 'slide_count' => 2],
['label_name' => 'Strophe 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nEN line 1\nEN line 2\n\nChorus\nEN chorus 1\nEN chorus 2\n\nVerse 2\nEN v2 1\nEN v2 2\n\n© 2024 Test\nCCLI # 9999002";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('German labels should normalize to match English CCLI labels')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][0]['ccli_label'])->toBe('Verse 1')
->and($result['mapping'][1]['ccli_label'])->toBe('Chorus');
});
test('returns unmatched_labels for sections not in CCLI', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Bridge', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\n\nChorus\nChorus line\n\n© 2024 Test\nCCLI # 9999003";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toContain('Bridge')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][2]['ccli_label'])->toBeNull()
->and($result['mapping'][2]['distributed_lines'])->toBe(['', '']);
});
test('distributes lines preserving local slide count', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\nLine 3\nLine 4\n\n© 2024\nCCLI # 9999004";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['mapping'][0]['distributed_lines'])->toHaveCount(2)
->and($result['distributed_text'])->toContain('Line 4');
});
test('uses linesTranslated from CCLI when available', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
]);
$ccliText = file_get_contents(base_path('tests/fixtures/ccli/english-german-side-by-side.txt'));
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['song'])->toBeInstanceOf(Song::class)
->and($result['mapping'])->toBeArray()
->and($result['distributed_text'])->toContain('Deutsche Liedzeile')
->and($result['distributed_text'])->toContain('Deutscher Refrain');
});