From 9f47f259c059a0cfa012999c910f099c96a84ad3 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 11 Mar 2026 16:35:29 +0100 Subject: [PATCH] validate chords #2 --- src/app/modules/songs/services/chord.ts | 4 +- .../services/text-rendering.service.spec.ts | 53 ++++++++++++++ .../songs/services/text-rendering.service.ts | 69 +++++++++++++++---- .../songs/services/transpose.service.ts | 4 +- 4 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/app/modules/songs/services/chord.ts b/src/app/modules/songs/services/chord.ts index 8e33c33..f4a7c16 100644 --- a/src/app/modules/songs/services/chord.ts +++ b/src/app/modules/songs/services/chord.ts @@ -13,7 +13,7 @@ export interface ChordValidationIssue { lineText: string; token: string; suggestion: string | null; - reason: 'alias' | 'minor_format' | 'major_format' | 'invalid_suffix' | 'unknown_token'; + reason: 'alias' | 'minor_format' | 'major_format' | 'invalid_suffix' | 'unknown_token' | 'tab_character'; message: string; } @@ -24,4 +24,6 @@ export interface Chord { slashChord: string | null; add: string | null; addDescriptor?: ChordAddDescriptor | null; + prefix?: string; + suffix?: string; } diff --git a/src/app/modules/songs/services/text-rendering.service.spec.ts b/src/app/modules/songs/services/text-rendering.service.spec.ts index eff3aef..405d0ac 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -276,6 +276,39 @@ Text`; ]); }); + it('should keep parentheses around alternative chord groups', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +C F G e C (F G) +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].type).toBe(LineType.chord); + void expect(sections[0].lines[0].text).toBe('C F G e C (F G)'); + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}, + {chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}, + {chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}, + {chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}, + {chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}, + {chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}, + {chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}, + ]); + }); + + it('should transpose multiple chords inside a parenthesized group', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +C F G e C (F G) +Text`; + + const sections = service.parse(text, {baseKey: 'C', targetKey: 'D'}); + + void expect(sections[0].lines[0].type).toBe(LineType.chord); + void expect(sections[0].lines[0].text).toBe('D G A f♯ D (G A)'); + }); + it('should treat compact prose-like tokens as text when the chord ratio is too low', () => { const service: TextRenderingService = TestBed.inject(TextRenderingService); const text = `Strophe @@ -399,4 +432,24 @@ Text`; jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null}), ]); }); + + it('should reject tabs on chord lines', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = 'Strophe\nC\tG\ta\nText'; + + void expect(service.validateChordNotation(text)).toContain( + jasmine.objectContaining({ + lineNumber: 2, + token: '\t', + reason: 'tab_character', + }) + ); + }); + + it('should not flag tabs on non chord lines', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = 'Strophe\nDas\tist normaler Text\nText'; + + void expect(service.validateChordNotation(text)).toEqual([]); + }); }); diff --git a/src/app/modules/songs/services/text-rendering.service.ts b/src/app/modules/songs/services/text-rendering.service.ts index 1f7a630..432c3fd 100644 --- a/src/app/modules/songs/services/text-rendering.service.ts +++ b/src/app/modules/songs/services/text-rendering.service.ts @@ -8,11 +8,13 @@ import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord'; import {Line} from './line'; interface ParsedValidationToken { + prefix: string; root: string; suffix: string; slashChord: string | null; rootWasAlias: boolean; slashWasAlias: boolean; + tokenSuffix: string; } interface ChordLineValidationResult { @@ -225,16 +227,24 @@ export class TextRenderingService { return {issues: [], isChordLike: false}; } + const issues: ChordValidationIssue[] = []; + if (line.includes('\t')) { + issues.push(this.createTabCharacterIssue(line, lineNumber)); + } + return { - issues: parsedTokens - .map(entry => { + issues: [ + ...issues, + ...parsedTokens + .map(entry => { if (!entry.parsed) { return this.createUnknownTokenIssue(line, lineNumber, entry.token); } return this.createValidationIssue(line, lineNumber, entry.token, entry.parsed); }) - .filter((issue): issue is ChordValidationIssue => issue !== null), + .filter((issue): issue is ChordValidationIssue => issue !== null), + ], isChordLike: true, }; } @@ -244,18 +254,19 @@ export class TextRenderingService { } private parseChordToken(token: string, position: number): Chord | null { - const root = this.readChordRoot(token, 0); + const decoratedToken = this.splitTokenDecorators(token); + const root = this.readChordRoot(decoratedToken.core, 0); if (!root) { return null; } let cursor = root.length; - const suffix = this.readChordSuffix(token, cursor); + const suffix = this.readChordSuffix(decoratedToken.core, cursor); cursor += suffix.length; let slashChord: string | null = null; - if (token[cursor] === '/') { - const slash = this.readChordRoot(token, cursor + 1); + if (decoratedToken.core[cursor] === '/') { + const slash = this.readChordRoot(decoratedToken.core, cursor + 1); if (!slash) { return null; } @@ -263,7 +274,7 @@ export class TextRenderingService { cursor += 1 + slash.length; } - if (cursor !== token.length) { + if (cursor !== decoratedToken.core.length) { return null; } @@ -274,6 +285,8 @@ export class TextRenderingService { slashChord, add: suffix || null, addDescriptor: this.parseChordAddDescriptor(suffix || null), + prefix: decoratedToken.prefix || undefined, + suffix: decoratedToken.suffix || undefined, }; } @@ -350,19 +363,20 @@ export class TextRenderingService { } private parseValidationToken(token: string): ParsedValidationToken | null { - const root = this.readValidationChordRoot(token, 0); + const decoratedToken = this.splitTokenDecorators(token); + const root = this.readValidationChordRoot(decoratedToken.core, 0); if (!root) { return null; } let cursor = root.length; - const suffix = this.readValidationChordSuffix(token, cursor); + const suffix = this.readValidationChordSuffix(decoratedToken.core, cursor); cursor += suffix.length; let slashChord: string | null = null; let slashWasAlias = false; - if (token[cursor] === '/') { - const slash = this.readValidationChordRoot(token, cursor + 1); + if (decoratedToken.core[cursor] === '/') { + const slash = this.readValidationChordRoot(decoratedToken.core, cursor + 1); if (!slash) { return null; } @@ -371,16 +385,18 @@ export class TextRenderingService { cursor += 1 + slash.length; } - if (cursor !== token.length) { + if (cursor !== decoratedToken.core.length) { return null; } return { + prefix: decoratedToken.prefix, root: root.root, suffix, slashChord, rootWasAlias: root.wasAlias, slashWasAlias, + tokenSuffix: decoratedToken.suffix, }; } @@ -449,6 +465,17 @@ export class TextRenderingService { }; } + private createTabCharacterIssue(line: string, lineNumber: number): ChordValidationIssue { + return { + lineNumber, + lineText: line, + token: '\t', + suggestion: null, + reason: 'tab_character', + message: 'Tabulatoren sind in Akkordzeilen nicht erlaubt, bitte Leerzeichen verwenden', + }; + } + private getCanonicalChordToken(parsed: ParsedValidationToken): string { const descriptor = this.parseChordAddDescriptor(parsed.suffix); const normalizedSuffix = this.normalizeSuffix(parsed.suffix); @@ -468,7 +495,7 @@ export class TextRenderingService { ? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord)) : null; - return root + suffix + (slashChord ? `/${slashChord}` : ''); + return parsed.prefix + root + suffix + (slashChord ? `/${slashChord}` : '') + parsed.tokenSuffix; } private normalizeSuffix(suffix: string): string { @@ -531,6 +558,20 @@ export class TextRenderingService { return root[0].toUpperCase() + root.slice(1); } + private splitTokenDecorators(token: string): {prefix: string; core: string; suffix: string} { + const prefixMatch = token.match(/^\(+/); + const suffixMatch = token.match(/\)+$/); + const prefix = prefixMatch?.[0] ?? ''; + const suffix = suffixMatch?.[0] ?? ''; + const core = token.slice(prefix.length, token.length - suffix.length); + + return { + prefix, + core, + suffix, + }; + } + private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null { if (!suffix) { return null; diff --git a/src/app/modules/songs/services/transpose.service.ts b/src/app/modules/songs/services/transpose.service.ts index 6652adc..3bd5b40 100644 --- a/src/app/modules/songs/services/transpose.service.ts +++ b/src/app/modules/songs/services/transpose.service.ts @@ -174,8 +174,10 @@ export class TransposeService { // Do not replace this with the original token without explicit product approval. const renderedChord = scaleMapping[chord.chord] ?? 'X'; const renderedSlashChord = chord.slashChord ? (scaleMapping[chord.slashChord] ?? 'X') : ''; + const prefix = chord.prefix ?? ''; + const suffix = chord.suffix ?? ''; - return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : ''); + return prefix + renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : '') + suffix; } private getSemitone(key: string): number | undefined {