validate chords #3

This commit is contained in:
2026-03-11 17:13:17 +01:00
parent 9f47f259c0
commit 0452ec55b2
11 changed files with 624 additions and 462 deletions

View File

@@ -17,6 +17,7 @@ describe('TextRenderingService', () => {
modifiers: [],
...partial,
});
const testText = `Strophe
C D E F G A H
Text Line 1-1
@@ -44,412 +45,454 @@ Cool bridge without any chords
void expect(service).toBeTruthy();
});
it('should parse section types', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].type).toBe(SectionType.Verse);
void expect(sections[0].number).toBe(0);
void expect(sections[1].type).toBe(SectionType.Verse);
void expect(sections[1].number).toBe(1);
void expect(sections[2].type).toBe(SectionType.Chorus);
void expect(sections[2].number).toBe(0);
void expect(sections[3].type).toBe(SectionType.Bridge);
void expect(sections[3].number).toBe(0);
});
describe('section parsing', () => {
it('should parse section types', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].type).toBe(SectionType.Verse);
void expect(sections[0].number).toBe(0);
void expect(sections[1].type).toBe(SectionType.Verse);
void expect(sections[1].number).toBe(1);
void expect(sections[2].type).toBe(SectionType.Chorus);
void expect(sections[2].number).toBe(0);
void expect(sections[3].type).toBe(SectionType.Bridge);
void expect(sections[3].number).toBe(0);
});
it('should parse text lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].lines[1].type).toBe(LineType.text);
void expect(sections[0].lines[1].text).toBe('Text Line 1-1');
void expect(sections[0].lines[3].type).toBe(LineType.text);
void expect(sections[0].lines[3].text).toBe('Text Line 2-1');
void expect(sections[1].lines[1].type).toBe(LineType.text);
void expect(sections[1].lines[1].text).toBe('Text Line 1-2');
void expect(sections[1].lines[3].type).toBe(LineType.text);
void expect(sections[1].lines[3].text).toBe('Text Line 2-2');
void expect(sections[2].lines[1].type).toBe(LineType.text);
void expect(sections[2].lines[1].text).toBe('and the chorus');
void expect(sections[3].lines[0].type).toBe(LineType.text);
void expect(sections[3].lines[0].text).toBe('Cool bridge without any chords');
});
it('should parse chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C D E F G A H');
void expect(sections[0].lines[2].type).toBe(LineType.chord);
void expect(sections[0].lines[2].text).toBe(' a d e f g a h c b');
void expect(sections[1].lines[0].type).toBe(LineType.chord);
void expect(sections[1].lines[0].text).toBe('C D E F G A H');
void expect(sections[1].lines[2].type).toBe(LineType.chord);
void expect(sections[1].lines[2].text).toBe(' a d e f g a h c b');
void expect(sections[2].lines[0].type).toBe(LineType.chord);
void expect(sections[2].lines[0].text).toBe('c c♯ d♭ c7 cmaj7 c/e');
// c c# db c7 cmaj7 c/e
void expect(sections[2].lines[0].chords).toEqual([
{chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null},
{chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null},
]);
});
it('should parse chords with a lot of symbols', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
g# F# E g# F# E
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('g♯ F♯ E g♯ F♯ E');
void expect(sections[0].lines[1].type).toBe(LineType.text);
void expect(sections[0].lines[1].text).toBe('text');
});
it('should ignore indented comment lines when comments are disabled', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
# hidden comment
Text`;
const sections = service.parse(text, null, false);
void expect(sections[0].lines.length).toBe(1);
void expect(sections[0].lines[0].text).toBe('Text');
});
it('should accept section headers with numbering and lowercase letters', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `strophe 1
it('should accept section headers with numbering and lowercase letters', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `strophe 1
Text A
Refrain 2
Text B`;
const sections = service.parse(text, null);
const sections = service.parse(text, null);
void expect(sections.length).toBe(2);
void expect(sections[0].type).toBe(SectionType.Verse);
void expect(sections[1].type).toBe(SectionType.Chorus);
});
void expect(sections.length).toBe(2);
void expect(sections[0].type).toBe(SectionType.Verse);
void expect(sections[1].type).toBe(SectionType.Chorus);
});
it('should return an empty array for empty input', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
it('should return an empty array for empty input', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
void expect(service.parse('', null)).toEqual([]);
});
void expect(service.parse('', null)).toEqual([]);
});
it('should ignore content before the first recognized section header', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Intro ohne Section
it('should ignore content before the first recognized section header', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Intro ohne Section
Noch eine Zeile
Strophe
Text`;
const sections = service.parse(text, null);
const sections = service.parse(text, null);
void expect(sections.length).toBe(1);
void expect(sections[0].lines.length).toBe(1);
void expect(sections[0].lines[0].text).toBe('Text');
void expect(sections.length).toBe(1);
void expect(sections[0].lines.length).toBe(1);
void expect(sections[0].lines[0].text).toBe('Text');
});
it('should support windows line endings', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = 'Strophe\r\nC D E\r\nText\r\nRefrain\r\nG A H';
const sections = service.parse(text, null);
void expect(sections.length).toBe(2);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[1].lines[0].type).toBe(LineType.chord);
});
});
it('should keep comment lines when comments are enabled', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
describe('comments and text lines', () => {
it('should parse text lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].lines[1].type).toBe(LineType.text);
void expect(sections[0].lines[1].text).toBe('Text Line 1-1');
void expect(sections[0].lines[3].type).toBe(LineType.text);
void expect(sections[0].lines[3].text).toBe('Text Line 2-1');
void expect(sections[1].lines[1].type).toBe(LineType.text);
void expect(sections[1].lines[1].text).toBe('Text Line 1-2');
void expect(sections[1].lines[3].type).toBe(LineType.text);
void expect(sections[1].lines[3].text).toBe('Text Line 2-2');
void expect(sections[2].lines[1].type).toBe(LineType.text);
void expect(sections[2].lines[1].text).toBe('and the chorus');
void expect(sections[3].lines[0].type).toBe(LineType.text);
void expect(sections[3].lines[0].text).toBe('Cool bridge without any chords');
});
it('should ignore indented comment lines when comments are disabled', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
# hidden comment
Text`;
const sections = service.parse(text, null, false);
void expect(sections[0].lines.length).toBe(1);
void expect(sections[0].lines[0].text).toBe('Text');
});
it('should keep comment lines when comments are enabled', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
# Kommentar
Text`;
const sections = service.parse(text, null, true);
const sections = service.parse(text, null, true);
void expect(sections[0].lines.length).toBe(2);
void expect(sections[0].lines[0].text).toBe('# Kommentar');
void expect(sections[0].lines[1].text).toBe('Text');
});
void expect(sections[0].lines.length).toBe(2);
void expect(sections[0].lines[0].text).toBe('# Kommentar');
void expect(sections[0].lines[1].text).toBe('Text');
});
it('should support windows line endings', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = 'Strophe\r\nC D E\r\nText\r\nRefrain\r\nG A H';
const sections = service.parse(text, null);
void expect(sections.length).toBe(2);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[1].lines[0].type).toBe(LineType.chord);
});
it('should not classify ordinary text with isolated note letters as a chord line', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
it('should not classify ordinary text with isolated note letters as a chord line', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Anna geht heute baden
Text`;
const sections = service.parse(text, null);
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
it('should preserve exact chord positions for spaced chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C G/B Am
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null},
{chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})},
]);
});
it('should parse common international chord suffixes and slash chords after the suffix', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Cmaj7 Dm7 Gsus4 Aadd9 Cmaj7/E
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})},
{chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})},
{chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})},
{chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
]);
});
it('should parse german chord suffixes', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Hmoll Edur Cverm Faug
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})},
{chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})},
{chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})},
]);
});
it('should parse numeric and altered chord suffixes', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C7 D9 E11 F13 Gadd#9 A7-5
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})},
{chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})},
{chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})},
{chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})},
{chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})},
]);
});
it('should parse lowercase roots with suffixes and slash chords', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
emoll d/F# cmaj7/e
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null},
{chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
]);
});
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
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
Das ist C nicht D sondern Text
Text`;
const sections = service.parse(text, null);
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
it('should call the transpose service when a transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough();
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough();
const text = `Strophe
C D E
Text`;
service.parse(text, {baseKey: 'C', targetKey: 'D'});
void expect(transposeSpy).toHaveBeenCalledTimes(2);
void expect(renderSpy).not.toHaveBeenCalled();
});
it('should use renderChords when no transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough();
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough();
const text = `Strophe
C D E
Text`;
service.parse(text, null);
void expect(renderSpy).toHaveBeenCalledTimes(2);
void expect(transposeSpy).not.toHaveBeenCalled();
});
it('should expose semantic descriptors for complex chord additions', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Cmaj7(add9) Dm7 Gsus4 A7-5
Text`;
const sections = service.parse(text, null);
const chords = sections[0].lines[0].chords ?? [];
void expect(chords[0].addDescriptor).toEqual(
descriptor('maj7(add9)', {
quality: 'major',
extensions: ['7'],
modifiers: ['(add9)'],
})
);
void expect(chords[1].addDescriptor).toEqual(descriptor('m7', {quality: 'minor', extensions: ['7']}));
void expect(chords[2].addDescriptor).toEqual(descriptor('sus4', {suspensions: ['4']}));
void expect(chords[3].addDescriptor).toEqual(descriptor('7-5', {extensions: ['7'], alterations: ['-5']}));
});
it('should report non-canonical sharp and flat aliases on chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Fis Hmoll Des/Fis
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Fis', suggestion: 'F#', reason: 'alias'}),
jasmine.objectContaining({lineNumber: 2, token: 'Hmoll', suggestion: 'h', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Des/Fis', suggestion: 'Db/F#', reason: 'alias'}),
]);
});
it('should report uppercase minor and lowercase major chord notation', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Am Dm7 cdur C
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}),
]);
});
it('should ignore prose lines even if they contain note-like words', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
it('should ignore prose lines even if they contain note-like words', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Heute singt Fis nicht mit
Text`;
void expect(service.validateChordNotation(text)).toEqual([]);
void expect(service.validateChordNotation(text)).toEqual([]);
});
});
it('should keep mostly-chord lines with unknown tokens in chord mode', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
describe('chord parsing', () => {
it('should parse chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C D E F G A H');
void expect(sections[0].lines[2].type).toBe(LineType.chord);
void expect(sections[0].lines[2].text).toBe(' a d e f g a h c b');
void expect(sections[1].lines[0].type).toBe(LineType.chord);
void expect(sections[1].lines[0].text).toBe('C D E F G A H');
void expect(sections[1].lines[2].type).toBe(LineType.chord);
void expect(sections[1].lines[2].text).toBe(' a d e f g a h c b');
void expect(sections[2].lines[0].type).toBe(LineType.chord);
void expect(sections[2].lines[0].text).toBe('c c♯ d♭ c7 cmaj7 c/e');
void expect(sections[2].lines[0].chords).toEqual([
{chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null},
{chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null},
]);
});
it('should parse chords with a lot of symbols', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
g# F# E g# F# E
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('g♯ F♯ E g♯ F♯ E');
void expect(sections[0].lines[1].type).toBe(LineType.text);
void expect(sections[0].lines[1].text).toBe('text');
});
it('should preserve exact chord positions for spaced chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C G/B Am
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null},
{chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})},
]);
});
it('should parse common international chord suffixes and slash chords after the suffix', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Cmaj7 Dm7 Gsus4 Aadd9 Cmaj7/E
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})},
{chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})},
{chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})},
{chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
]);
});
it('should parse german chord suffixes', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Hmoll Edur Cverm Faug
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})},
{chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})},
{chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})},
]);
});
it('should parse numeric and altered chord suffixes', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C7 D9 E11 F13 Gadd#9 A7-5
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})},
{chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})},
{chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})},
{chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})},
{chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})},
]);
});
it('should parse lowercase roots with suffixes and slash chords', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
emoll d/F# cmaj7/e
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null},
{chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
]);
});
it('should expose semantic descriptors for complex chord additions', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Cmaj7(add9) Dm7 Gsus4 A7-5
Text`;
const sections = service.parse(text, null);
const chords = sections[0].lines[0].chords ?? [];
void expect(chords[0].addDescriptor).toEqual(
descriptor('maj7(add9)', {
quality: 'major',
extensions: ['7'],
modifiers: ['(add9)'],
})
);
void expect(chords[1].addDescriptor).toEqual(descriptor('m7', {quality: 'minor', extensions: ['7']}));
void expect(chords[2].addDescriptor).toEqual(descriptor('sus4', {suspensions: ['4']}));
void expect(chords[3].addDescriptor).toEqual(descriptor('7-5', {extensions: ['7'], alterations: ['-5']}));
});
it('should not misinterpret modifier parentheses as group wrappers', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Cmaj7(add9)
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].text).toBe('Cmaj7(add9)');
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 11, position: 0, add: 'maj7(add9)', slashChord: null, addDescriptor: descriptor('maj7(add9)', {
quality: 'major',
extensions: ['7'],
modifiers: ['(add9)'],
})},
]);
void expect(service.validateChordNotation(text)).toEqual([]);
});
});
describe('parenthesized groups', () => {
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)');
});
});
describe('transpose integration', () => {
it('should call the transpose service when a transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough();
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough();
const text = `Strophe
C D E
Text`;
service.parse(text, {baseKey: 'C', targetKey: 'D'});
void expect(transposeSpy).toHaveBeenCalledTimes(2);
void expect(renderSpy).not.toHaveBeenCalled();
});
it('should use renderChords when no transpose mode is provided', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const transposeService = TestBed.inject(TransposeService);
const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough();
const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough();
const text = `Strophe
C D E
Text`;
service.parse(text, null);
void expect(renderSpy).toHaveBeenCalledTimes(2);
void expect(transposeSpy).not.toHaveBeenCalled();
});
});
describe('notation validation', () => {
it('should report non-canonical sharp and flat aliases on chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Fis Hmoll Des/Fis
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Fis', suggestion: 'F#', reason: 'alias'}),
jasmine.objectContaining({lineNumber: 2, token: 'Hmoll', suggestion: 'h', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Des/Fis', suggestion: 'Db/F#', reason: 'alias'}),
]);
});
it('should report uppercase minor and lowercase major chord notation', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Am Dm7 cdur C
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}),
]);
});
it('should keep slash bass notes uppercase in canonical minor suggestions', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Am/C# Dm7/F#
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Am/C#', suggestion: 'a/C#', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Dm7/F#', suggestion: 'd7/F#', reason: 'minor_format'}),
]);
});
it('should keep mostly-chord lines with unknown tokens in chord mode', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C Es G
Text`;
const sections = service.parse(text, null);
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 Es G');
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'alias', suggestion: 'Eb'}),
]);
});
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C Es G');
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'alias', suggestion: 'Eb'}),
]);
});
it('should flag unknown tokens on mostly chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
it('should flag unknown tokens on mostly chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C Foo G a
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null}),
]);
});
void expect(service.validateChordNotation(text)).toEqual([
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';
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',
})
);
});
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';
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([]);
void expect(service.validateChordNotation(text)).toEqual([]);
});
});
});

View File

@@ -7,6 +7,48 @@ import {LineType} from './line-type';
import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord';
import {Line} from './line';
const CHORD_ROOT_DEFINITIONS = [
{canonical: 'C#', aliases: ['Cis']},
{canonical: 'Db', aliases: ['Des']},
{canonical: 'D#', aliases: ['Dis']},
{canonical: 'Eb', aliases: ['Es']},
{canonical: 'F#', aliases: ['Fis']},
{canonical: 'Gb', aliases: ['Ges']},
{canonical: 'G#', aliases: ['Gis']},
{canonical: 'Ab', aliases: ['As']},
{canonical: 'A#', aliases: ['Ais']},
{canonical: 'C', aliases: []},
{canonical: 'D', aliases: []},
{canonical: 'E', aliases: []},
{canonical: 'F', aliases: []},
{canonical: 'G', aliases: []},
{canonical: 'A', aliases: []},
{canonical: 'B', aliases: ['Hb']},
{canonical: 'H', aliases: []},
{canonical: 'c#', aliases: ['cis']},
{canonical: 'db', aliases: ['des']},
{canonical: 'd#', aliases: ['dis']},
{canonical: 'eb', aliases: ['es']},
{canonical: 'f#', aliases: ['fis']},
{canonical: 'gb', aliases: ['ges']},
{canonical: 'g#', aliases: ['gis']},
{canonical: 'ab', aliases: ['as']},
{canonical: 'a#', aliases: ['ais']},
{canonical: 'c', aliases: []},
{canonical: 'd', aliases: []},
{canonical: 'e', aliases: []},
{canonical: 'f', aliases: []},
{canonical: 'g', aliases: []},
{canonical: 'a', aliases: []},
{canonical: 'b', aliases: ['hb']},
{canonical: 'h', aliases: []},
] as const;
const CANONICAL_CHORD_ROOTS = CHORD_ROOT_DEFINITIONS.map(entry => entry.canonical);
const ALTERNATIVE_CHORD_ROOTS = Object.fromEntries(
CHORD_ROOT_DEFINITIONS.flatMap(entry => entry.aliases.map(alias => [alias, entry.canonical]))
) as Record<string, string>;
interface ParsedValidationToken {
prefix: string;
root: string;
@@ -18,7 +60,9 @@ interface ParsedValidationToken {
}
interface ChordLineValidationResult {
chords: Chord[];
issues: ChordValidationIssue[];
isStrictChordLine: boolean;
isChordLike: boolean;
}
@@ -29,66 +73,10 @@ export class TextRenderingService {
private transposeService = inject(TransposeService);
private readonly regexSection = /^\s*(Strophe|Refrain|Bridge)\b/i;
private readonly chordRoots = [
'C#',
'Db',
'D#',
'Eb',
'F#',
'Gb',
'G#',
'Ab',
'A#',
'C',
'D',
'E',
'F',
'G',
'A',
'B',
'H',
'c#',
'db',
'd#',
'eb',
'f#',
'gb',
'g#',
'ab',
'a#',
'c',
'd',
'e',
'f',
'g',
'a',
'b',
'h',
] as const;
private readonly chordRoots = CANONICAL_CHORD_ROOTS;
private readonly suffixKeywords = ['moll', 'verm', 'maj', 'min', 'dur', 'dim', 'aug', 'sus', 'add', 'm'] as const;
private readonly suffixChars = new Set(['#', 'b', '+', '-', '(', ')']);
private readonly alternativeChordRoots: Record<string, string> = {
Ais: 'A#',
ais: 'a#',
As: 'Ab',
as: 'ab',
Cis: 'C#',
cis: 'c#',
Des: 'Db',
des: 'db',
Dis: 'D#',
dis: 'd#',
Es: 'Eb',
es: 'eb',
Fis: 'F#',
fis: 'f#',
Ges: 'Gb',
ges: 'gb',
Gis: 'G#',
gis: 'g#',
Hb: 'B',
hb: 'b',
};
private readonly alternativeChordRoots = ALTERNATIVE_CHORD_ROOTS;
public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] {
if (!text) {
@@ -153,14 +141,15 @@ export class TextRenderingService {
private getLineOfLineText(text: string, transpose: TransposeMode | null, lineNumber?: number): Line | null {
if (!text) return null;
const chords = this.readChords(text);
const validationResult = lineNumber ? this.getChordLineValidationResult(text, lineNumber) : {issues: [], isChordLike: false};
const validationResult = lineNumber
? this.getChordLineValidationResult(text, lineNumber)
: {chords: [], issues: [], isStrictChordLine: false, isChordLike: false};
const validationIssues = validationResult.issues;
const hasMatches = chords.length > 0;
const hasMatches = validationResult.isStrictChordLine;
const isChordLikeLine = hasMatches || validationResult.isChordLike;
const type = isChordLikeLine ? LineType.chord : LineType.text;
const line: Line = {type, text, chords: hasMatches ? chords : null, lineNumber};
const line: Line = {type, text, chords: hasMatches ? validationResult.chords : null, lineNumber};
if (validationIssues.length > 0 || (!hasMatches && isChordLikeLine)) {
return line;
}
@@ -189,7 +178,7 @@ export class TextRenderingService {
return null;
}
private readChords(chordLine: string): Chord[] {
private getParsedChords(chordLine: string): Chord[] {
const chords: Chord[] = [];
const tokens = chordLine.match(/\S+/g) ?? [];
@@ -201,14 +190,12 @@ export class TextRenderingService {
}
}
const chordCount = chords.reduce((acc: number, cur: Chord) => acc + cur.length, 0);
const lineCount = chordLine.replace(/\s/g, '').length;
const isChordLine = chordCount * 1.2 > lineCount;
return isChordLine ? chords : [];
return chords;
}
private getChordLineValidationResult(line: string, lineNumber: number): ChordLineValidationResult {
const tokens = line.match(/\S+/g) ?? [];
const chords = this.getParsedChords(line);
const parsedTokens = tokens
.map(token => ({
token,
@@ -217,14 +204,16 @@ export class TextRenderingService {
const recognizedTokens = parsedTokens.filter((entry): entry is {token: string; parsed: ParsedValidationToken} => entry.parsed !== null);
const strictChordCount = chords.reduce((sum, chord) => sum + chord.length, 0);
const parsedLength = recognizedTokens.reduce((sum, entry) => sum + entry.token.length, 0);
const compactLineLength = line.replace(/\s/g, '').length;
const isStrictChordLine = compactLineLength > 0 && strictChordCount * 1.2 > compactLineLength;
const strictlyChordLike = compactLineLength > 0 && parsedLength * 1.2 > compactLineLength;
const heuristicallyChordLike = tokens.length >= 2 && recognizedTokens.length >= Math.ceil(tokens.length * 0.6);
const isChordLike = strictlyChordLike || heuristicallyChordLike;
if (!isChordLike) {
return {issues: [], isChordLike: false};
return {chords, issues: [], isStrictChordLine, isChordLike: false};
}
const issues: ChordValidationIssue[] = [];
@@ -233,6 +222,7 @@ export class TextRenderingService {
}
return {
chords,
issues: [
...issues,
...parsedTokens
@@ -245,6 +235,7 @@ export class TextRenderingService {
})
.filter((issue): issue is ChordValidationIssue => issue !== null),
],
isStrictChordLine,
isChordLike: true,
};
}
@@ -492,7 +483,7 @@ export class TextRenderingService {
}
const slashChord = parsed.slashChord
? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord))
? this.toMajorRoot(parsed.slashChord)
: null;
return parsed.prefix + root + suffix + (slashChord ? `/${slashChord}` : '') + parsed.tokenSuffix;
@@ -559,11 +550,35 @@ export class TextRenderingService {
}
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);
let prefix = '';
let suffix = '';
let core = token;
while (core.startsWith('(') && !this.isFullyWrappedByOuterParentheses(core)) {
const matchingClosingParen = this.findMatchingClosingParen(core, 0);
if (matchingClosingParen !== -1) {
break;
}
prefix += '(';
core = core.slice(1);
}
while (core.endsWith(')') && !this.isFullyWrappedByOuterParentheses(core)) {
const matchingOpeningParen = this.findMatchingOpeningParen(core, core.length - 1);
if (matchingOpeningParen !== -1) {
break;
}
suffix = ')' + suffix;
core = core.slice(0, -1);
}
while (this.isFullyWrappedByOuterParentheses(core)) {
prefix += '(';
suffix = ')' + suffix;
core = core.slice(1, -1);
}
return {
prefix,
@@ -572,6 +587,52 @@ export class TextRenderingService {
};
}
private isFullyWrappedByOuterParentheses(value: string): boolean {
if (!value.startsWith('(') || !value.endsWith(')')) {
return false;
}
return this.findMatchingClosingParen(value, 0) === value.length - 1;
}
private findMatchingClosingParen(value: string, start: number): number {
let depth = 0;
for (let i = start; i < value.length; i++) {
if (value[i] === '(') {
depth++;
} else if (value[i] === ')') {
depth--;
if (depth === 0) {
return i;
}
if (depth < 0) {
return -1;
}
}
}
return -1;
}
private findMatchingOpeningParen(value: string, end: number): number {
let depth = 0;
for (let i = end; i >= 0; i--) {
if (value[i] === ')') {
depth++;
} else if (value[i] === '(') {
depth--;
if (depth === 0) {
return i;
}
if (depth < 0) {
return -1;
}
}
}
return -1;
}
private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null {
if (!suffix) {
return null;

View File

@@ -7,7 +7,12 @@
@for (song of songs; track trackBy($index, song)) {
<div [routerLink]="song.id" class="list-item">
<div class="number">{{ song.number }}</div>
<div>{{ song.title }}</div>
<div class="title">
<span>{{ song.title }}</span>
@if (song.hasChordValidationIssues) {
<span class="validation-star" title="Akkord-Validierungsfehler vorhanden">*</span>
}
</div>
<div>
<ng-container *appRole="['contributor']">
@if (song.status === 'draft') {

View File

@@ -27,6 +27,15 @@
text-align: right;
}
.title {
gap: 6px;
}
.validation-star {
color: var(--danger);
font-weight: bold;
}
.neutral, .warning, .success {
width: 30px;
}

View File

@@ -9,6 +9,7 @@ import {filterSong} from '../../../services/filter.helper';
import {FilterValues} from './filter/filter-values';
import {ScrollService} from '../../../services/scroll.service';
import {faBalanceScaleRight, faCheck, faPencilRuler} from '@fortawesome/free-solid-svg-icons';
import {TextRenderingService} from '../services/text-rendering.service';
import {AsyncPipe} from '@angular/common';
import {ListHeaderComponent} from '../../../widget-modules/components/list-header/list-header.component';
import {FilterComponent} from './filter/filter.component';
@@ -16,6 +17,10 @@ import {CardComponent} from '../../../widget-modules/components/card/card.compon
import {RoleDirective} from '../../../services/user/role.directive';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
interface SongListItem extends Song {
hasChordValidationIssues: boolean;
}
@Component({
selector: 'app-songs',
templateUrl: './song-list.component.html',
@@ -28,15 +33,22 @@ export class SongListComponent implements OnInit, OnDestroy {
private songService = inject(SongService);
private activatedRoute = inject(ActivatedRoute);
private scrollService = inject(ScrollService);
private textRenderingService = inject(TextRenderingService);
public anyFilterActive = false;
public songs$: Observable<Song[]> = combineLatest([
public songs$: Observable<SongListItem[]> = combineLatest([
this.activatedRoute.queryParams.pipe(map(_ => _ as FilterValues)),
this.songService.list$().pipe(map(songs => [...songs].sort((a, b) => a.number - b.number))),
]).pipe(
map(([filter, songs]) => {
this.anyFilterActive = this.checkIfFilterActive(filter);
return songs.filter(song => this.filter(song, filter)).sort((a, b) => a.title?.localeCompare(b.title));
return songs
.filter(song => this.filter(song, filter))
.map(song => ({
...song,
hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0,
}))
.sort((a, b) => a.title?.localeCompare(b.title));
})
);
public faLegal = faBalanceScaleRight;
@@ -52,7 +64,7 @@ export class SongListComponent implements OnInit, OnDestroy {
this.scrollService.storeScrollPositionFor('songlist');
}
public trackBy = (index: number, show: Song) => show.id;
public trackBy = (index: number, show: SongListItem) => show.id;
private filter(song: Song, filter: FilterValues): boolean {
let baseFilter = filterSong(song, filter.q);

View File

@@ -73,7 +73,7 @@
@if (songtextFocus) {
<div class="song-text-help">
<h3>Vorschau</h3>
<app-song-text [invalidChordIssues]="chordValidationIssues" [text]="form.value.text" chordMode="show"></app-song-text>
<app-song-text [text]="form.value.text" [validateChordNotation]="true" chordMode="show"></app-song-text>
<h3>Hinweise zur Bearbeitung</h3>
<h4>Aufbau</h4>
Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen,

View File

@@ -4,6 +4,7 @@
> * {
width: 100%;
box-sizing: border-box;
}
.fourth {
@@ -65,4 +66,8 @@ h4 {
border-radius: 4px;
background: rgba(125, 25, 25, 0.08);
}
h3 {
margin: 0;
}
}

View File

@@ -3,7 +3,7 @@
<app-card
[heading]="song.number + ' - ' + song.title"
closeLink="../"
>
>
<div class="song">
<div>
<div *appRole="['leader', 'contributor']" class="detail">
@@ -21,7 +21,7 @@
<a
href="https://songselect.ccli.com/Songs/{{ song.legalOwnerId }}"
target="_blank"
>
>
CCLI Nummer: {{ song.legalOwnerId }}
</a>
</div>
@@ -48,15 +48,16 @@
[chordMode]="user.chordMode"
[showSwitch]="true"
[text]="song.text"
[validateChordNotation]="true"
></app-song-text>
}
<mat-chip-listbox
*appRole="['leader', 'contributor']"
aria-label="Attribute"
>
>
@for (flag of getFlags(song.flags); track flag) {
<mat-chip-option>{{
flag
flag
}}
</mat-chip-option>
}
@@ -70,10 +71,10 @@
(click)="onDelete(song.id)"
*appRole="['admin']"
[icon]="faDelete"
>Löschen
>Löschen
</app-button>
<app-button *appRole="['contributor']" [icon]="faEdit" routerLink="edit"
>Bearbeiten
>Bearbeiten
</app-button>
<ng-container *appRole="['leader']">
<app-button [icon]="faFileCirclePlus" [matMenuTriggerFor]="menu">