From 0452ec55b2a231f6e6ea1a6131eff95f45af8a63 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 11 Mar 2026 17:13:17 +0100 Subject: [PATCH] validate chords #3 --- .../services/text-rendering.service.spec.ts | 761 +++++++++--------- .../songs/services/text-rendering.service.ts | 209 +++-- .../songs/song-list/song-list.component.html | 7 +- .../songs/song-list/song-list.component.less | 9 + .../songs/song-list/song-list.component.ts | 18 +- .../edit/edit-song/edit-song.component.html | 2 +- .../edit/edit-song/edit-song.component.less | 5 + .../modules/songs/song/song.component.html | 13 +- .../song-text/song-text.component.html | 4 +- .../song-text/song-text.component.less | 9 + .../song-text/song-text.component.ts | 49 +- 11 files changed, 624 insertions(+), 462 deletions(-) diff --git a/src/app/modules/songs/services/text-rendering.service.spec.ts b/src/app/modules/songs/services/text-rendering.service.spec.ts index 405d0ac..973e838 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -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([]); + }); }); }); diff --git a/src/app/modules/songs/services/text-rendering.service.ts b/src/app/modules/songs/services/text-rendering.service.ts index 432c3fd..b37bafb 100644 --- a/src/app/modules/songs/services/text-rendering.service.ts +++ b/src/app/modules/songs/services/text-rendering.service.ts @@ -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; + 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 = { - 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; diff --git a/src/app/modules/songs/song-list/song-list.component.html b/src/app/modules/songs/song-list/song-list.component.html index c5c1599..5d34a00 100644 --- a/src/app/modules/songs/song-list/song-list.component.html +++ b/src/app/modules/songs/song-list/song-list.component.html @@ -7,7 +7,12 @@ @for (song of songs; track trackBy($index, song)) {
{{ song.number }}
-
{{ song.title }}
+
+ {{ song.title }} + @if (song.hasChordValidationIssues) { + * + } +
@if (song.status === 'draft') { diff --git a/src/app/modules/songs/song-list/song-list.component.less b/src/app/modules/songs/song-list/song-list.component.less index 8aee505..a5dd51a 100644 --- a/src/app/modules/songs/song-list/song-list.component.less +++ b/src/app/modules/songs/song-list/song-list.component.less @@ -27,6 +27,15 @@ text-align: right; } +.title { + gap: 6px; +} + +.validation-star { + color: var(--danger); + font-weight: bold; +} + .neutral, .warning, .success { width: 30px; } diff --git a/src/app/modules/songs/song-list/song-list.component.ts b/src/app/modules/songs/song-list/song-list.component.ts index f4f7129..f495d7e 100644 --- a/src/app/modules/songs/song-list/song-list.component.ts +++ b/src/app/modules/songs/song-list/song-list.component.ts @@ -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 = combineLatest([ + public songs$: Observable = 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); diff --git a/src/app/modules/songs/song/edit/edit-song/edit-song.component.html b/src/app/modules/songs/song/edit/edit-song/edit-song.component.html index 16d1747..61aef39 100644 --- a/src/app/modules/songs/song/edit/edit-song/edit-song.component.html +++ b/src/app/modules/songs/song/edit/edit-song/edit-song.component.html @@ -73,7 +73,7 @@ @if (songtextFocus) {

Vorschau

- +

Hinweise zur Bearbeitung

Aufbau

Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen, diff --git a/src/app/modules/songs/song/edit/edit-song/edit-song.component.less b/src/app/modules/songs/song/edit/edit-song/edit-song.component.less index d7ddd28..d92b086 100644 --- a/src/app/modules/songs/song/edit/edit-song/edit-song.component.less +++ b/src/app/modules/songs/song/edit/edit-song/edit-song.component.less @@ -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; + } } diff --git a/src/app/modules/songs/song/song.component.html b/src/app/modules/songs/song/song.component.html index acea852..53232e8 100644 --- a/src/app/modules/songs/song/song.component.html +++ b/src/app/modules/songs/song/song.component.html @@ -3,7 +3,7 @@ + >
@@ -48,15 +48,16 @@ [chordMode]="user.chordMode" [showSwitch]="true" [text]="song.text" + [validateChordNotation]="true" > } + > @for (flag of getFlags(song.flags); track flag) { {{ - flag + flag }} } @@ -70,10 +71,10 @@ (click)="onDelete(song.id)" *appRole="['admin']" [icon]="faDelete" - >Löschen + >Löschen Bearbeiten + >Bearbeiten diff --git a/src/app/widget-modules/components/song-text/song-text.component.html b/src/app/widget-modules/components/song-text/song-text.component.html index cb3a0ae..ca451c6 100644 --- a/src/app/widget-modules/components/song-text/song-text.component.html +++ b/src/app/widget-modules/components/song-text/song-text.component.html @@ -28,7 +28,7 @@ class="line" > @for (segment of getDisplaySegments(line); track $index) { - {{ segment.text }} + {{ segment.text }} }
} @@ -77,7 +77,7 @@ class="line" > @for (segment of getDisplaySegments(line, true); track $index) { - {{ segment.text }} + {{ segment.text }} }
} diff --git a/src/app/widget-modules/components/song-text/song-text.component.less b/src/app/widget-modules/components/song-text/song-text.component.less index 742b352..5593226 100644 --- a/src/app/widget-modules/components/song-text/song-text.component.less +++ b/src/app/widget-modules/components/song-text/song-text.component.less @@ -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; diff --git a/src/app/widget-modules/components/song-text/song-text.component.ts b/src/app/widget-modules/components/song-text/song-text.component.ts index afeee0f..644483c 100644 --- a/src/app/widget-modules/components/song-text/song-text.component.ts +++ b/src/app/widget-modules/components/song-text/song-text.component.ts @@ -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(); 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(); - (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(); + return; + } + + const issuesByLine = new Map(); + 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':