diff --git a/.sisyphus/evidence/task-8-cross-lang-pair.txt b/.sisyphus/evidence/task-8-cross-lang-pair.txt new file mode 100644 index 0000000..f3ebc75 --- /dev/null +++ b/.sisyphus/evidence/task-8-cross-lang-pair.txt @@ -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 diff --git a/.sisyphus/evidence/task-8-unmatched-bridge.txt b/.sisyphus/evidence/task-8-unmatched-bridge.txt new file mode 100644 index 0000000..8198ddf --- /dev/null +++ b/.sisyphus/evidence/task-8-unmatched-bridge.txt @@ -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 diff --git a/.sisyphus/notepads/ccli-songselect-import/issues.md b/.sisyphus/notepads/ccli-songselect-import/issues.md index 07df7ad..2d687d9 100644 --- a/.sisyphus/notepads/ccli-songselect-import/issues.md +++ b/.sisyphus/notepads/ccli-songselect-import/issues.md @@ -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. diff --git a/.sisyphus/notepads/ccli-songselect-import/learnings.md b/.sisyphus/notepads/ccli-songselect-import/learnings.md index 67db235..151f6a6 100644 --- a/.sisyphus/notepads/ccli-songselect-import/learnings.md +++ b/.sisyphus/notepads/ccli-songselect-import/learnings.md @@ -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. diff --git a/app/Services/CcliTranslationPairingService.php b/app/Services/CcliTranslationPairingService.php new file mode 100644 index 0000000..dd07e60 --- /dev/null +++ b/app/Services/CcliTranslationPairingService.php @@ -0,0 +1,148 @@ +, + * 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 + */ + 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 $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; + } +} diff --git a/database/migrations/2026_05_10_120000_add_imported_from_ccli_to_songs_table.php b/database/migrations/2026_05_10_120000_add_imported_from_ccli_to_songs_table.php index 1bbd553..738116e 100644 --- a/database/migrations/2026_05_10_120000_add_imported_from_ccli_to_songs_table.php +++ b/database/migrations/2026_05_10_120000_add_imported_from_ccli_to_songs_table.php @@ -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 { diff --git a/tests/Feature/CcliPasteParserScaffoldTest.php b/tests/Feature/CcliPasteParserScaffoldTest.php index 8adce39..7307d2b 100644 --- a/tests/Feature/CcliPasteParserScaffoldTest.php +++ b/tests/Feature/CcliPasteParserScaffoldTest.php @@ -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"); diff --git a/tests/Feature/CcliPasteParserTest.php b/tests/Feature/CcliPasteParserTest.php index b919483..cc9d9c9 100644 --- a/tests/Feature/CcliPasteParserTest.php +++ b/tests/Feature/CcliPasteParserTest.php @@ -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(''); diff --git a/tests/Feature/CcliTranslationPairingServiceTest.php b/tests/Feature/CcliTranslationPairingServiceTest.php new file mode 100644 index 0000000..4348b14 --- /dev/null +++ b/tests/Feature/CcliTranslationPairingServiceTest.php @@ -0,0 +1,129 @@ +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'); +});