From ae4459f5ceff6a14789646c4805d6540c6523d7a Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 11 Mar 2026 16:18:36 +0100 Subject: [PATCH] validate chords --- src/app/modules/songs/services/chord.ts | 9 + src/app/modules/songs/services/line.ts | 1 + .../services/text-rendering.service.spec.ts | 61 ++++ .../songs/services/text-rendering.service.ts | 329 +++++++++++++++++- .../edit/edit-song/edit-song.component.html | 20 +- .../edit/edit-song/edit-song.component.less | 23 ++ .../edit/edit-song/edit-song.component.ts | 27 +- .../components/button/button.component.html | 2 +- .../components/button/button.component.ts | 1 + .../song-text/song-text.component.html | 10 +- .../song-text/song-text.component.less | 5 + .../song-text/song-text.component.ts | 63 ++++ 12 files changed, 538 insertions(+), 13 deletions(-) diff --git a/src/app/modules/songs/services/chord.ts b/src/app/modules/songs/services/chord.ts index 3f37988..8e33c33 100644 --- a/src/app/modules/songs/services/chord.ts +++ b/src/app/modules/songs/services/chord.ts @@ -8,6 +8,15 @@ export interface ChordAddDescriptor { modifiers: string[]; } +export interface ChordValidationIssue { + lineNumber: number; + lineText: string; + token: string; + suggestion: string | null; + reason: 'alias' | 'minor_format' | 'major_format' | 'invalid_suffix' | 'unknown_token'; + message: string; +} + export interface Chord { chord: string; length: number; diff --git a/src/app/modules/songs/services/line.ts b/src/app/modules/songs/services/line.ts index 2916ad2..9ae73f9 100644 --- a/src/app/modules/songs/services/line.ts +++ b/src/app/modules/songs/services/line.ts @@ -5,4 +5,5 @@ export interface Line { type: LineType; text: string; chords: Chord[] | null; + lineNumber?: number; } 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 919be05..eff3aef 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -338,4 +338,65 @@ Text`; 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 +Heute singt Fis nicht mit +Text`; + + 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 +C Es 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 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 +C Foo G a +Text`; + + void expect(service.validateChordNotation(text)).toEqual([ + jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null}), + ]); + }); }); diff --git a/src/app/modules/songs/services/text-rendering.service.ts b/src/app/modules/songs/services/text-rendering.service.ts index b40fc23..1f7a630 100644 --- a/src/app/modules/songs/services/text-rendering.service.ts +++ b/src/app/modules/songs/services/text-rendering.service.ts @@ -4,9 +4,22 @@ import {TransposeMode} from './transpose-mode'; import {SectionType} from './section-type'; import {Section} from './section'; import {LineType} from './line-type'; -import {Chord, ChordAddDescriptor} from './chord'; +import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord'; import {Line} from './line'; +interface ParsedValidationToken { + root: string; + suffix: string; + slashChord: string | null; + rootWasAlias: boolean; + slashWasAlias: boolean; +} + +interface ChordLineValidationResult { + issues: ChordValidationIssue[]; + isChordLike: boolean; +} + @Injectable({ providedIn: 'root', }) @@ -52,6 +65,28 @@ export class TextRenderingService { ] as const; 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', + }; public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] { if (!text) { @@ -65,7 +100,7 @@ export class TextRenderingService { }; const sections: Section[] = []; - for (const line of text.split(/\r?\n/)) { + for (const [lineIndex, line] of text.split(/\r?\n/).entries()) { if (!line || this.isCommentLine(line, withComments)) { continue; } @@ -84,7 +119,7 @@ export class TextRenderingService { continue; } - const renderedLine = this.getLineOfLineText(line, transpose); + const renderedLine = this.getLineOfLineText(line, transpose, lineIndex + 1); if (renderedLine) { sections[sections.length - 1].lines.push(renderedLine); } @@ -93,14 +128,41 @@ export class TextRenderingService { return sections; } - private getLineOfLineText(text: string, transpose: TransposeMode | null): Line | null { + public validateChordNotation(text: string, withComments = true): ChordValidationIssue[] { + if (!text) { + return []; + } + + const issues: ChordValidationIssue[] = []; + const lines = text.split(/\r?\n/); + + lines.forEach((line, lineIndex) => { + if (!line || this.isCommentLine(line, withComments) || this.getSectionTypeOfLine(line) !== null) { + return; + } + + const validationResult = this.getChordLineValidationResult(line, lineIndex + 1); + issues.push(...validationResult.issues); + }); + + return issues; + } + + 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 validationIssues = validationResult.issues; const hasMatches = chords.length > 0; - const type = hasMatches ? LineType.chord : LineType.text; + const isChordLikeLine = hasMatches || validationResult.isChordLike; + const type = isChordLikeLine ? LineType.chord : LineType.text; + + const line: Line = {type, text, chords: hasMatches ? chords : null, lineNumber}; + if (validationIssues.length > 0 || (!hasMatches && isChordLikeLine)) { + return line; + } - const line: Line = {type, text, chords: hasMatches ? chords : null}; return transpose !== null && transpose !== undefined ? this.transposeService.transpose(line, transpose.baseKey, transpose.targetKey) : this.transposeService.renderChords(line); } @@ -143,6 +205,40 @@ export class TextRenderingService { return isChordLine ? chords : []; } + private getChordLineValidationResult(line: string, lineNumber: number): ChordLineValidationResult { + const tokens = line.match(/\S+/g) ?? []; + const parsedTokens = tokens + .map(token => ({ + token, + parsed: this.parseValidationToken(token), + })); + + const recognizedTokens = parsedTokens.filter((entry): entry is {token: string; parsed: ParsedValidationToken} => entry.parsed !== null); + + const parsedLength = recognizedTokens.reduce((sum, entry) => sum + entry.token.length, 0); + const compactLineLength = line.replace(/\s/g, '').length; + 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 { + issues: parsedTokens + .map(entry => { + if (!entry.parsed) { + return this.createUnknownTokenIssue(line, lineNumber, entry.token); + } + + return this.createValidationIssue(line, lineNumber, entry.token, entry.parsed); + }) + .filter((issue): issue is ChordValidationIssue => issue !== null), + isChordLike: true, + }; + } + private isCommentLine(line: string, withComments: boolean): boolean { return !withComments && line.trimStart().startsWith('#'); } @@ -185,6 +281,20 @@ export class TextRenderingService { return this.chordRoots.find(root => token.startsWith(root, start)) ?? null; } + private readValidationChordRoot(token: string, start: number): {root: string; length: number; wasAlias: boolean} | null { + const directMatch = this.readChordRoot(token, start); + if (directMatch) { + return {root: directMatch, length: directMatch.length, wasAlias: false}; + } + + const aliasMatch = Object.entries(this.alternativeChordRoots).find(([alias]) => token.startsWith(alias, start)); + if (!aliasMatch) { + return null; + } + + return {root: aliasMatch[1], length: aliasMatch[0].length, wasAlias: true}; + } + private readChordSuffix(token: string, start: number): string { let cursor = start; let suffix = ''; @@ -210,10 +320,217 @@ export class TextRenderingService { return suffix; } + private readValidationChordSuffix(token: string, start: number): string { + let cursor = start; + let suffix = ''; + + while (cursor < token.length) { + const keyword = this.suffixKeywords.find(entry => token.slice(cursor, cursor + entry.length).toLowerCase() === entry); + if (keyword) { + suffix += token.slice(cursor, cursor + keyword.length); + cursor += keyword.length; + continue; + } + + const char = token[cursor]; + if (this.isDigit(char) || this.suffixChars.has(char)) { + suffix += char; + cursor += 1; + continue; + } + + break; + } + + return suffix; + } + private isDigit(char: string | undefined): boolean { return !!char && char >= '0' && char <= '9'; } + private parseValidationToken(token: string): ParsedValidationToken | null { + const root = this.readValidationChordRoot(token, 0); + if (!root) { + return null; + } + + let cursor = root.length; + const suffix = this.readValidationChordSuffix(token, cursor); + cursor += suffix.length; + + let slashChord: string | null = null; + let slashWasAlias = false; + if (token[cursor] === '/') { + const slash = this.readValidationChordRoot(token, cursor + 1); + if (!slash) { + return null; + } + slashChord = slash.root; + slashWasAlias = slash.wasAlias; + cursor += 1 + slash.length; + } + + if (cursor !== token.length) { + return null; + } + + return { + root: root.root, + suffix, + slashChord, + rootWasAlias: root.wasAlias, + slashWasAlias, + }; + } + + private createValidationIssue(line: string, lineNumber: number, token: string, parsed: ParsedValidationToken): ChordValidationIssue | null { + const suggestion = this.getCanonicalChordToken(parsed); + if (suggestion === token) { + return null; + } + + if (parsed.rootWasAlias || parsed.slashWasAlias) { + return { + lineNumber, + lineText: line, + token, + suggestion, + reason: 'alias', + message: `Bitte #/b statt is/es verwenden: ${suggestion}`, + }; + } + + const descriptor = this.parseChordAddDescriptor(parsed.suffix); + if (descriptor?.quality === 'minor' && this.isMajorRoot(parsed.root)) { + return { + lineNumber, + lineText: line, + token, + suggestion, + reason: 'minor_format', + message: `Mollakkorde bitte mit kleinem Grundton schreiben: ${suggestion}`, + }; + } + + if (this.isMinorRoot(parsed.root) && descriptor?.quality === 'major') { + return { + lineNumber, + lineText: line, + token, + suggestion, + reason: 'major_format', + message: `Durakkorde bitte mit grossem Grundton schreiben: ${suggestion}`, + }; + } + + if (parsed.suffix !== this.normalizeSuffix(parsed.suffix)) { + return { + lineNumber, + lineText: line, + token, + suggestion, + reason: 'invalid_suffix', + message: `Bitte die vorgegebene Akkordschreibweise verwenden: ${suggestion}`, + }; + } + + return null; + } + + private createUnknownTokenIssue(line: string, lineNumber: number, token: string): ChordValidationIssue { + return { + lineNumber, + lineText: line, + token, + suggestion: null, + reason: 'unknown_token', + message: 'Unbekannter Akkord oder falsche Schreibweise', + }; + } + + private getCanonicalChordToken(parsed: ParsedValidationToken): string { + const descriptor = this.parseChordAddDescriptor(parsed.suffix); + const normalizedSuffix = this.normalizeSuffix(parsed.suffix); + + let root = parsed.root; + let suffix = normalizedSuffix; + + if (descriptor?.quality === 'minor') { + root = this.toMinorRoot(root); + suffix = this.stripLeadingMinorMarker(normalizedSuffix); + } else if (descriptor?.quality === 'major') { + root = this.toMajorRoot(root); + suffix = this.stripLeadingDurMarker(normalizedSuffix); + } + + const slashChord = parsed.slashChord + ? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord)) + : null; + + return root + suffix + (slashChord ? `/${slashChord}` : ''); + } + + private normalizeSuffix(suffix: string): string { + if (!suffix) { + return suffix; + } + + let rest = suffix; + let normalized = ''; + + const prefixMap: Array<[string, string]> = [ + ['moll', 'moll'], + ['min', 'min'], + ['maj', 'maj'], + ['dur', 'dur'], + ['dim', 'dim'], + ['verm', 'verm'], + ['aug', 'aug'], + ['sus', 'sus'], + ['add', 'add'], + ['m', 'm'], + ]; + + while (rest.length > 0) { + const keyword = prefixMap.find(([key]) => rest.slice(0, key.length).toLowerCase() === key); + if (keyword) { + normalized += keyword[1]; + rest = rest.slice(keyword[0].length); + continue; + } + + normalized += rest[0]; + rest = rest.slice(1); + } + + return normalized; + } + + private stripLeadingMinorMarker(suffix: string): string { + return suffix.replace(/^(moll|min|m)/, ''); + } + + private stripLeadingDurMarker(suffix: string): string { + return suffix.replace(/^dur/, ''); + } + + private isMajorRoot(root: string): boolean { + return root[0] === root[0].toUpperCase(); + } + + private isMinorRoot(root: string): boolean { + return root[0] === root[0].toLowerCase(); + } + + private toMinorRoot(root: string): string { + return root[0].toLowerCase() + root.slice(1); + } + + private toMajorRoot(root: string): string { + return root[0].toUpperCase() + root.slice(1); + } + private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null { if (!suffix) { return null; 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 77acc35..16d1747 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 @@ -54,10 +54,26 @@ matInput > + @if (chordValidationIssues.length > 0) { +
+

Akkordschreibweise korrigieren

+ @for (issue of chordValidationIssues; track issue.lineNumber + '-' + issue.token) { +
+ Zeile {{ issue.lineNumber }}: + {{ issue.message }} + {{ issue.token }} + @if (issue.suggestion) { + -> + {{ issue.suggestion }} + } +
+ } +
+ } @if (songtextFocus) {

Vorschau

- +

Hinweise zur Bearbeitung

Aufbau

Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen, @@ -180,7 +196,7 @@
- Speichern + Speichern } 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 a91148e..d7ddd28 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 @@ -43,3 +43,26 @@ h4 { .song-text-help { font-size: 11px; } + +.song-text-validation { + margin: -8px 0 12px; + padding: 12px 14px; + border-radius: 6px; + background: rgba(166, 32, 32, 0.08); + border: 1px solid rgba(166, 32, 32, 0.22); + color: #7d1919; + + .issue { + display: flex; + gap: 8px; + align-items: baseline; + flex-wrap: wrap; + margin-top: 6px; + } + + code { + padding: 1px 4px; + border-radius: 4px; + background: rgba(125, 25, 25, 0.08); + } +} diff --git a/src/app/modules/songs/song/edit/edit-song/edit-song.component.ts b/src/app/modules/songs/song/edit/edit-song/edit-song.component.ts index 323d488..ab09400 100644 --- a/src/app/modules/songs/song/edit/edit-song/edit-song.component.ts +++ b/src/app/modules/songs/song/edit/edit-song/edit-song.component.ts @@ -5,12 +5,15 @@ import {ActivatedRoute, Router, RouterStateSnapshot} from '@angular/router'; import {SongService} from '../../../services/song.service'; import {EditService} from '../edit.service'; import {first, map, switchMap} from 'rxjs/operators'; +import {startWith} from 'rxjs'; import {KEYS} from '../../../services/key.helper'; import {COMMA, ENTER} from '@angular/cdk/keycodes'; import {MatChipGrid, MatChipInput, MatChipInputEvent, MatChipRow} from '@angular/material/chips'; import {faExternalLinkAlt, faSave, faTimesCircle} from '@fortawesome/free-solid-svg-icons'; import {MatDialog} from '@angular/material/dialog'; import {SaveDialogComponent} from './save-dialog/save-dialog.component'; +import {TextRenderingService} from '../../../services/text-rendering.service'; +import {ChordValidationIssue} from '../../../services/chord'; import {CardComponent} from '../../../../../widget-modules/components/card/card.component'; import {MatFormField, MatLabel, MatSuffix} from '@angular/material/form-field'; @@ -63,6 +66,7 @@ export class EditSongComponent implements OnInit { private songService = inject(SongService); private editService = inject(EditService); private router = inject(Router); + private textRenderingService = inject(TextRenderingService); public dialog = inject(MatDialog); public song: Song | null = null; @@ -78,6 +82,7 @@ export class EditSongComponent implements OnInit { public faSave = faSave; public faLink = faExternalLinkAlt; public songtextFocus = false; + public chordValidationIssues: ChordValidationIssue[] = []; public ngOnInit(): void { this.activatedRoute.params @@ -92,12 +97,15 @@ export class EditSongComponent implements OnInit { if (!song) return; this.form = this.editService.createSongForm(song); this.form.controls.flags.valueChanges.subscribe(_ => this.onFlagsChanged(_ as string)); + this.form.controls.text.valueChanges.pipe(startWith(this.form.controls.text.value)).subscribe(text => { + this.updateChordValidation(text as string); + }); this.onFlagsChanged(this.form.controls.flags.value as string); }); } public async onSave(): Promise { - if (!this.song) return; + if (!this.song || this.form.invalid) return; const data = this.form.value as Partial; await this.songService.update$(this.song.id, data); this.form.markAsPristine(); @@ -149,8 +157,23 @@ export class EditSongComponent implements OnInit { this.flags = flagArray.split(';').filter(_ => !!_); } + private updateChordValidation(text: string): void { + const control = this.form.controls.text; + this.chordValidationIssues = this.textRenderingService.validateChordNotation(text ?? ''); + + const errors = {...(control.errors ?? {})}; + if (this.chordValidationIssues.length > 0) { + errors.chordNotation = this.chordValidationIssues; + control.setErrors(errors); + return; + } + + delete errors.chordNotation; + control.setErrors(Object.keys(errors).length > 0 ? errors : null); + } + private async onSaveDialogAfterClosed(save: boolean, url: string) { - if (save && this.song) { + if (save && this.song && !this.form.invalid) { const data = this.form.value as Partial; await this.songService.update$(this.song.id, data); } diff --git a/src/app/widget-modules/components/button/button.component.html b/src/app/widget-modules/components/button/button.component.html index ca2d923..476954d 100644 --- a/src/app/widget-modules/components/button/button.component.html +++ b/src/app/widget-modules/components/button/button.component.html @@ -1,4 +1,4 @@ -