validate chords #3

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

View File

@@ -17,6 +17,7 @@ describe('TextRenderingService', () => {
modifiers: [],
...partial,
});
const testText = `Strophe
C D E F G A H
Text Line 1-1
@@ -44,6 +45,7 @@ Cool bridge without any chords
void expect(service).toBeTruthy();
});
describe('section parsing', () => {
it('should parse section types', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText, null);
@@ -57,72 +59,6 @@ Cool bridge without any chords
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
@@ -157,6 +93,48 @@ Text`;
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);
});
});
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
@@ -170,17 +148,6 @@ Text`;
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
@@ -193,6 +160,65 @@ Text`;
void expect(sections[0].lines[0].chords).toBeNull();
});
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);
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
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([]);
});
});
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
@@ -276,6 +302,48 @@ Text`;
]);
});
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
@@ -308,19 +376,9 @@ Text`;
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
Das ist C nicht D sondern Text
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.text);
void expect(sections[0].lines[0].chords).toBeNull();
});
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);
@@ -350,28 +408,9 @@ Text`;
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']}));
});
describe('notation validation', () => {
it('should report non-canonical sharp and flat aliases on chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
@@ -398,13 +437,16 @@ Text`;
]);
});
it('should ignore prose lines even if they contain note-like words', () => {
it('should keep slash bass notes uppercase in canonical minor suggestions', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
Heute singt Fis nicht mit
Am/C# Dm7/F#
Text`;
void expect(service.validateChordNotation(text)).toEqual([]);
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', () => {
@@ -452,4 +494,5 @@ Text`;
void expect(service.validateChordNotation(text)).toEqual([]);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@
[chordMode]="user.chordMode"
[showSwitch]="true"
[text]="song.text"
[validateChordNotation]="true"
></app-song-text>
}
<mat-chip-listbox

View File

@@ -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>
}

View File

@@ -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;

View File

@@ -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':