Refactor lyric storage so each song owns its sections instead of sharing global labels. Adds song_sections (per song+label) owning song_slides; labels stay global ProPresenter group tags (name/color/macro). Arrangements now reference sections, so editing/importing one song no longer corrupts others that share a label name. - New: song_sections table + migration with safe backfill; SongSection, SongArrangementSection models; SongSectionController (edit/add/delete sections, immediate persistence) wired into SongEditModal. - Refactor writers/readers: CcliImport, ProImport, SongService, ArrangementController, SongController, ProExport, PDF, Translation (translation reset now section-scoped), CCLI pairing. - CCLI import fixes: parse SongSelect copy-icon format (German "Vers" abbrev + trailing author), fill empty CTS-synced songs instead of blocking as duplicate, distinct label colors per section kind, import&edit/existing-song open the edit modal (no 404/405), teleport paste dialog above assign dialog, preview shows section content, correct SongSelect search URL, copy-icon instructions. - Bookmarklet clicks #generalCopyLyricsButton and captures clipboard; serves correct host from request. - Export: embed key-visual/background under fixed bundle-relative names. - Tests updated for the section model; new section + isolation coverage.
136 lines
5.1 KiB
PHP
136 lines
5.1 KiB
PHP
<?php
|
|
|
|
use App\Models\Label;
|
|
use App\Models\Song;
|
|
use App\Models\SongArrangement;
|
|
use App\Models\SongArrangementLabel;
|
|
use App\Models\SongSection;
|
|
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'],
|
|
);
|
|
$section = SongSection::create([
|
|
'song_id' => $song->id,
|
|
'label_id' => $label->id,
|
|
'order' => $order + 1,
|
|
]);
|
|
|
|
SongArrangementLabel::create([
|
|
'song_arrangement_id' => $arrangement->id,
|
|
'song_section_id' => $section->id,
|
|
'order' => $order + 1,
|
|
]);
|
|
|
|
for ($i = 0; $i < $slideCount; $i++) {
|
|
SongSlide::create([
|
|
'song_section_id' => $section->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');
|
|
});
|