From e4e5df912e22036ac764026127c88f4752c51c26 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 10 May 2026 18:54:33 +0200 Subject: [PATCH] fix(ccli): parse common CCLI metadata --- .../ccli-songselect-import/learnings.md | 4 ++++ app/Services/CcliPasteParser.php | 16 +++++++++------- app/Support/CcliLabels.php | 13 +++++++++++++ tests/Feature/CcliPasteParserTest.php | 19 ++++++++++++++++--- tests/Unit/CcliLabelsTest.php | 11 +++++++++++ 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.sisyphus/notepads/ccli-songselect-import/learnings.md b/.sisyphus/notepads/ccli-songselect-import/learnings.md index b67f90b..6cf9603 100644 --- a/.sisyphus/notepads/ccli-songselect-import/learnings.md +++ b/.sisyphus/notepads/ccli-songselect-import/learnings.md @@ -84,3 +84,7 @@ ### 2026-05-10 CcliPasteParser Implementation - Parser trims pasted lines, treats blank lines as separators, extracts first two header lines as title/author, and excludes CCLI metadata from lyric sections. - EN/DE side-by-side imports merge only adjacent labels with different raw kinds but the same `CcliLabels::normalizeLabelName()` canonical kind/number, preserving German lyrics in `linesTranslated`. - DDEV/Linux path is `tests/fixtures/ccli` (lowercase); macOS accepted `tests/Fixtures/ccli`, but tests must use lowercase for container portability. + +### 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. diff --git a/app/Services/CcliPasteParser.php b/app/Services/CcliPasteParser.php index 4e41111..83fee56 100644 --- a/app/Services/CcliPasteParser.php +++ b/app/Services/CcliPasteParser.php @@ -63,8 +63,9 @@ public function parse(string $rawText): ParsedCcliSong } if ($isMetadataLine($line)) { - if (preg_match('/CCLI[\s#-]*(\d+)/iu', $line, $matches)) { - $ccliId = $matches[1]; + $extractedCcliId = CcliLabels::extractCcliId($line); + if ($extractedCcliId !== null) { + $ccliId = $extractedCcliId; } if (str_contains($line, '©')) { @@ -90,7 +91,8 @@ public function parse(string $rawText): ParsedCcliSong $current = [ 'label' => $line, - 'kind' => $label['kind'], + 'kind' => CcliLabels::normalizeLabelName($label['kind']), + 'rawKind' => $label['kind'], 'number' => $label['number'], 'modifier' => $label['modifier'], 'lines' => [], @@ -126,7 +128,7 @@ public function parse(string $rawText): ParsedCcliSong } /** - * @param array $sections + * @param array $sections * @return ParsedCcliSection[] */ private function mergeTranslatedSections(array $sections): array @@ -160,12 +162,12 @@ private function mergeTranslatedSections(array $sections): array } /** - * @param array{kind: string, number: string|null} $section - * @param array{kind: string, number: string|null} $next + * @param array{kind: string, rawKind: string, number: string|null} $section + * @param array{kind: string, rawKind: string, number: string|null} $next */ private function isTranslatedPair(array $section, array $next): bool { - return mb_strtolower($section['kind']) !== mb_strtolower($next['kind']) + return mb_strtolower($section['rawKind']) !== mb_strtolower($next['rawKind']) && $this->canonicalLabel($section) === $this->canonicalLabel($next); } diff --git a/app/Support/CcliLabels.php b/app/Support/CcliLabels.php index 12e320d..a43691d 100644 --- a/app/Support/CcliLabels.php +++ b/app/Support/CcliLabels.php @@ -36,6 +36,19 @@ public static function isMetadataLine(string $line): bool return (bool) preg_match(self::METADATA_PATTERN, $line); } + public static function extractCcliId(string $line): ?string + { + if (preg_match('/CCLI\s*(?:License|Lizenz)/iu', $line)) { + return null; + } + + if (! preg_match('/CCLI(?:[\s-]*(?:Song|Lied(?:nummer)?|Nr\.?))?[\s#:\-.]*(\d+)/iu', $line, $matches)) { + return null; + } + + return $matches[1]; + } + public static function normalizeLabelName(string $label): string { $trimmed = trim($label); diff --git a/tests/Feature/CcliPasteParserTest.php b/tests/Feature/CcliPasteParserTest.php index ffd093a..b919483 100644 --- a/tests/Feature/CcliPasteParserTest.php +++ b/tests/Feature/CcliPasteParserTest.php @@ -62,13 +62,26 @@ function ccliFixtureContent(string $filename): string expect($first->linesTranslated)->not->toBeEmpty(); }); -test('german-only.txt detects German section labels', function (): void { +test('german-only.txt detects German labels and normalizes section kind', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('german-only.txt')); + $labels = array_map(fn (ParsedCcliSection $section): string => $section->label, $result->sections); $kinds = array_map(fn (ParsedCcliSection $section): string => $section->kind, $result->sections); - $hasGermanKind = array_filter($kinds, fn (string $kind): bool => in_array(mb_strtolower($kind), ['strophe', 'refrain', 'brücke'], true)); - expect(count($hasGermanKind))->toBeGreaterThanOrEqual(1, 'Should detect at least one German section label'); + + expect($labels)->toContain('Strophe 1'); + expect($kinds)->toContain('Verse'); + expect($kinds)->toContain('Chorus'); +}); + +test('common CCLI metadata formats extract the song ID but not license numbers', function (): void { + $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'); + + $result = $parser->parse("Test Song\nTest Artist\n\nStrophe 1\nZeile\n\nCCLI-Nr. 7654321"); + expect($result->ccliId)->toBe('7654321'); }); test('repeat-marker.txt preserves modifier in section DTO', function (): void { diff --git a/tests/Unit/CcliLabelsTest.php b/tests/Unit/CcliLabelsTest.php index 4fc7b57..7c92fe3 100644 --- a/tests/Unit/CcliLabelsTest.php +++ b/tests/Unit/CcliLabelsTest.php @@ -68,6 +68,17 @@ 'Test Song 1', ]); +test('extractCcliId parses song number metadata without license numbers', function (string $line, ?string $expected) { + expect(CcliLabels::extractCcliId($line))->toBe($expected); +})->with([ + ['CCLI # 4760', '4760'], + ['CCLI Song # 1234567', '1234567'], + ['CCLI-Nr. 7654321', '7654321'], + ['CCLI-Liednummer 9999001', '9999001'], + ['CCLI License # 123456', null], + ['CCLI-Lizenz # 123456', null], +]); + test('normalizeLabelName converts german labels to english', function (string $input, string $expected) { expect(CcliLabels::normalizeLabelName($input))->toBe($expected); })->with([