validate chords #3
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.title {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.validation-star {
|
||||
color: var(--danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.neutral, .warning, .success {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
class="line"
|
||||
>
|
||||
@for (segment of getDisplaySegments(line); track $index) {
|
||||
<span [class.invalid-chord-token]="segment.invalid">{{ segment.text }}</span>
|
||||
<span [class.invalid-chord-token]="segment.invalid" [class.invalid-tab-token]="segment.isTab">{{ segment.text }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
class="line"
|
||||
>
|
||||
@for (segment of getDisplaySegments(line, true); track $index) {
|
||||
<span [class.invalid-chord-token]="segment.invalid">{{ segment.text }}</span>
|
||||
<span [class.invalid-chord-token]="segment.invalid" [class.invalid-tab-token]="segment.isTab">{{ segment.text }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.invalid-tab-token {
|
||||
display: inline-block;
|
||||
min-width: 1.5ch;
|
||||
text-align: center;
|
||||
background: rgba(180, 35, 24, 0.12);
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
|
||||
@@ -17,6 +17,7 @@ export type ChordMode = 'show' | 'hide' | 'onlyFirst';
|
||||
interface DisplaySegment {
|
||||
text: string;
|
||||
invalid: boolean;
|
||||
isTab: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -42,6 +43,7 @@ export class SongTextComponent implements OnInit {
|
||||
public faLines = faGripLines;
|
||||
public offset = 0;
|
||||
public iChordMode: ChordMode = 'hide';
|
||||
public showValidationIssues = false;
|
||||
private invalidChordIssuesByLine = new Map<number, ChordValidationIssue[]>();
|
||||
private iText = '';
|
||||
private iTranspose: TransposeMode | null = null;
|
||||
@@ -64,14 +66,9 @@ export class SongTextComponent implements OnInit {
|
||||
}
|
||||
|
||||
@Input()
|
||||
public set invalidChordIssues(value: ChordValidationIssue[] | null) {
|
||||
const issuesByLine = new Map<number, ChordValidationIssue[]>();
|
||||
(value ?? []).forEach(issue => {
|
||||
const lineIssues = issuesByLine.get(issue.lineNumber) ?? [];
|
||||
lineIssues.push(issue);
|
||||
issuesByLine.set(issue.lineNumber, lineIssues);
|
||||
});
|
||||
this.invalidChordIssuesByLine = issuesByLine;
|
||||
public set validateChordNotation(value: boolean) {
|
||||
this.showValidationIssues = value;
|
||||
this.updateValidationIssues();
|
||||
this.cRef.markForCheck();
|
||||
}
|
||||
|
||||
@@ -129,41 +126,45 @@ export class SongTextComponent implements OnInit {
|
||||
const text = trim ? line.text.trim() : this.transform(line.text);
|
||||
const lineNumber = line.lineNumber;
|
||||
if (!lineNumber) {
|
||||
return [{text, invalid: false}];
|
||||
return [{text, invalid: false, isTab: false}];
|
||||
}
|
||||
|
||||
const issues = this.invalidChordIssuesByLine.get(lineNumber);
|
||||
if (!issues || issues.length === 0) {
|
||||
return [{text, invalid: false}];
|
||||
return [{text, invalid: false, isTab: false}];
|
||||
}
|
||||
|
||||
const ranges: Array<{start: number; end: number}> = [];
|
||||
const ranges: Array<{start: number; end: number; isTab: boolean}> = [];
|
||||
let searchStart = 0;
|
||||
issues.forEach(issue => {
|
||||
const index = text.indexOf(issue.token, searchStart);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
ranges.push({start: index, end: index + issue.token.length});
|
||||
ranges.push({start: index, end: index + issue.token.length, isTab: issue.reason === 'tab_character'});
|
||||
searchStart = index + issue.token.length;
|
||||
});
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return [{text, invalid: false}];
|
||||
return [{text, invalid: false, isTab: false}];
|
||||
}
|
||||
|
||||
const segments: DisplaySegment[] = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach(range => {
|
||||
if (range.start > cursor) {
|
||||
segments.push({text: text.slice(cursor, range.start), invalid: false});
|
||||
segments.push({text: text.slice(cursor, range.start), invalid: false, isTab: false});
|
||||
}
|
||||
segments.push({text: text.slice(range.start, range.end), invalid: true});
|
||||
segments.push({
|
||||
text: range.isTab ? '⇥' : text.slice(range.start, range.end),
|
||||
invalid: true,
|
||||
isTab: range.isTab,
|
||||
});
|
||||
cursor = range.end;
|
||||
});
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({text: text.slice(cursor), invalid: false});
|
||||
segments.push({text: text.slice(cursor), invalid: false, isTab: false});
|
||||
}
|
||||
|
||||
return segments;
|
||||
@@ -176,6 +177,7 @@ export class SongTextComponent implements OnInit {
|
||||
private render() {
|
||||
this.offset = 0;
|
||||
this.sections = [];
|
||||
this.updateValidationIssues();
|
||||
if (this.fullscreen) {
|
||||
setTimeout(() => {
|
||||
this.sections = this.textRenderingService.parse(this.iText, this.iTranspose, this.showComments);
|
||||
@@ -187,6 +189,21 @@ export class SongTextComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private updateValidationIssues(): void {
|
||||
if (!this.showValidationIssues) {
|
||||
this.invalidChordIssuesByLine = new Map<number, ChordValidationIssue[]>();
|
||||
return;
|
||||
}
|
||||
|
||||
const issuesByLine = new Map<number, ChordValidationIssue[]>();
|
||||
this.textRenderingService.validateChordNotation(this.iText, this.showComments).forEach(issue => {
|
||||
const lineIssues = issuesByLine.get(issue.lineNumber) ?? [];
|
||||
lineIssues.push(issue);
|
||||
issuesByLine.set(issue.lineNumber, lineIssues);
|
||||
});
|
||||
this.invalidChordIssuesByLine = issuesByLine;
|
||||
}
|
||||
|
||||
private getNextChordMode(): ChordMode {
|
||||
switch (this.iChordMode) {
|
||||
case 'show':
|
||||
|
||||
Reference in New Issue
Block a user