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:
parent
091e00f255
commit
cd44d6289c
17
.sisyphus/evidence/task-8-cross-lang-pair.txt
Normal file
17
.sisyphus/evidence/task-8-cross-lang-pair.txt
Normal 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
|
||||
18
.sisyphus/evidence/task-8-unmatched-bridge.txt
Normal file
18
.sisyphus/evidence/task-8-unmatched-bridge.txt
Normal 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
|
||||
|
|
@ -29,3 +29,7 @@ ### Translation Pairing Label Direction
|
|||
### T7: Global Label Slide Replacement Caveat
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -94,3 +94,8 @@ ### 2026-05-10 CcliImportService Implementation
|
|||
### 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.
|
||||
- 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.
|
||||
|
|
|
|||
148
app/Services/CcliTranslationPairingService.php
Normal file
148
app/Services/CcliTranslationPairingService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('songs', function (Blueprint $table): void {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use App\Services\DTO\ParsedCcliSong;
|
||||
|
||||
test('CcliPasteParser can be instantiated with no arguments', function (): void {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
expect($parser)->toBeInstanceOf(CcliPasteParser::class);
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
});
|
||||
|
||||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function ccliFixtureContent(string $filename): string
|
|||
}
|
||||
|
||||
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) {
|
||||
$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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('english-only-multi-verse.txt'));
|
||||
|
||||
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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('english-german-side-by-side.txt'));
|
||||
|
||||
$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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('german-only.txt'));
|
||||
|
||||
$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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
$result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nLine\n\nCCLI Song # 1234567\nCCLI License # 111222");
|
||||
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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('repeat-marker.txt'));
|
||||
|
||||
$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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('umlauts.txt'));
|
||||
|
||||
$allText = $result->title;
|
||||
|
|
@ -105,7 +105,7 @@ function ccliFixtureContent(string $filename): string
|
|||
});
|
||||
|
||||
test('missing-copyright.txt returns null copyrightText', function (): void {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$result = $parser->parse(ccliFixtureContent('missing-copyright.txt'));
|
||||
|
||||
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 {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
$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));
|
||||
|
|
@ -122,19 +122,19 @@ function ccliFixtureContent(string $filename): string
|
|||
});
|
||||
|
||||
test('parse throws InvalidArgumentException on empty input', function (): void {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('parse error messages are in German', function (): void {
|
||||
$parser = new CcliPasteParser();
|
||||
$parser = new CcliPasteParser;
|
||||
|
||||
try {
|
||||
$parser->parse('');
|
||||
|
|
|
|||
129
tests/Feature/CcliTranslationPairingServiceTest.php
Normal file
129
tests/Feature/CcliTranslationPairingServiceTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue