validate chords
This commit is contained in:
@@ -8,6 +8,15 @@ export interface ChordAddDescriptor {
|
|||||||
modifiers: string[];
|
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 {
|
export interface Chord {
|
||||||
chord: string;
|
chord: string;
|
||||||
length: number;
|
length: number;
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export interface Line {
|
|||||||
type: LineType;
|
type: LineType;
|
||||||
text: string;
|
text: string;
|
||||||
chords: Chord[] | null;
|
chords: Chord[] | null;
|
||||||
|
lineNumber?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,4 +338,65 @@ Text`;
|
|||||||
void expect(chords[2].addDescriptor).toEqual(descriptor('sus4', {suspensions: ['4']}));
|
void expect(chords[2].addDescriptor).toEqual(descriptor('sus4', {suspensions: ['4']}));
|
||||||
void expect(chords[3].addDescriptor).toEqual(descriptor('7-5', {extensions: ['7'], alterations: ['-5']}));
|
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}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ import {TransposeMode} from './transpose-mode';
|
|||||||
import {SectionType} from './section-type';
|
import {SectionType} from './section-type';
|
||||||
import {Section} from './section';
|
import {Section} from './section';
|
||||||
import {LineType} from './line-type';
|
import {LineType} from './line-type';
|
||||||
import {Chord, ChordAddDescriptor} from './chord';
|
import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord';
|
||||||
import {Line} from './line';
|
import {Line} from './line';
|
||||||
|
|
||||||
|
interface ParsedValidationToken {
|
||||||
|
root: string;
|
||||||
|
suffix: string;
|
||||||
|
slashChord: string | null;
|
||||||
|
rootWasAlias: boolean;
|
||||||
|
slashWasAlias: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChordLineValidationResult {
|
||||||
|
issues: ChordValidationIssue[];
|
||||||
|
isChordLike: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -52,6 +65,28 @@ export class TextRenderingService {
|
|||||||
] as const;
|
] as const;
|
||||||
private readonly suffixKeywords = ['moll', 'verm', 'maj', 'min', 'dur', 'dim', 'aug', 'sus', 'add', 'm'] 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 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',
|
||||||
|
};
|
||||||
|
|
||||||
public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] {
|
public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -65,7 +100,7 @@ export class TextRenderingService {
|
|||||||
};
|
};
|
||||||
const sections: Section[] = [];
|
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)) {
|
if (!line || this.isCommentLine(line, withComments)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -84,7 +119,7 @@ export class TextRenderingService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedLine = this.getLineOfLineText(line, transpose);
|
const renderedLine = this.getLineOfLineText(line, transpose, lineIndex + 1);
|
||||||
if (renderedLine) {
|
if (renderedLine) {
|
||||||
sections[sections.length - 1].lines.push(renderedLine);
|
sections[sections.length - 1].lines.push(renderedLine);
|
||||||
}
|
}
|
||||||
@@ -93,14 +128,41 @@ export class TextRenderingService {
|
|||||||
return sections;
|
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;
|
if (!text) return null;
|
||||||
|
|
||||||
const chords = this.readChords(text);
|
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 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);
|
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 : [];
|
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 {
|
private isCommentLine(line: string, withComments: boolean): boolean {
|
||||||
return !withComments && line.trimStart().startsWith('#');
|
return !withComments && line.trimStart().startsWith('#');
|
||||||
}
|
}
|
||||||
@@ -185,6 +281,20 @@ export class TextRenderingService {
|
|||||||
return this.chordRoots.find(root => token.startsWith(root, start)) ?? null;
|
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 {
|
private readChordSuffix(token: string, start: number): string {
|
||||||
let cursor = start;
|
let cursor = start;
|
||||||
let suffix = '';
|
let suffix = '';
|
||||||
@@ -210,10 +320,217 @@ export class TextRenderingService {
|
|||||||
return suffix;
|
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 {
|
private isDigit(char: string | undefined): boolean {
|
||||||
return !!char && char >= '0' && char <= '9';
|
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 {
|
private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null {
|
||||||
if (!suffix) {
|
if (!suffix) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -54,10 +54,26 @@
|
|||||||
matInput
|
matInput
|
||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
@if (chordValidationIssues.length > 0) {
|
||||||
|
<div class="song-text-validation">
|
||||||
|
<h3>Akkordschreibweise korrigieren</h3>
|
||||||
|
@for (issue of chordValidationIssues; track issue.lineNumber + '-' + issue.token) {
|
||||||
|
<div class="issue">
|
||||||
|
<strong>Zeile {{ issue.lineNumber }}:</strong>
|
||||||
|
<span>{{ issue.message }}</span>
|
||||||
|
<code>{{ issue.token }}</code>
|
||||||
|
@if (issue.suggestion) {
|
||||||
|
<span>-></span>
|
||||||
|
<code>{{ issue.suggestion }}</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (songtextFocus) {
|
@if (songtextFocus) {
|
||||||
<div class="song-text-help">
|
<div class="song-text-help">
|
||||||
<h3>Vorschau</h3>
|
<h3>Vorschau</h3>
|
||||||
<app-song-text [text]="form.value.text" chordMode="show"></app-song-text>
|
<app-song-text [invalidChordIssues]="chordValidationIssues" [text]="form.value.text" chordMode="show"></app-song-text>
|
||||||
<h3>Hinweise zur Bearbeitung</h3>
|
<h3>Hinweise zur Bearbeitung</h3>
|
||||||
<h4>Aufbau</h4>
|
<h4>Aufbau</h4>
|
||||||
Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen,
|
Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen,
|
||||||
@@ -180,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<app-button-row>
|
<app-button-row>
|
||||||
<app-button (click)="onSave()" [icon]="faSave">Speichern</app-button>
|
<app-button (click)="onSave()" [disabled]="form.invalid" [icon]="faSave">Speichern</app-button>
|
||||||
</app-button-row>
|
</app-button-row>
|
||||||
</app-card>
|
</app-card>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,26 @@ h4 {
|
|||||||
.song-text-help {
|
.song-text-help {
|
||||||
font-size: 11px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {ActivatedRoute, Router, RouterStateSnapshot} from '@angular/router';
|
|||||||
import {SongService} from '../../../services/song.service';
|
import {SongService} from '../../../services/song.service';
|
||||||
import {EditService} from '../edit.service';
|
import {EditService} from '../edit.service';
|
||||||
import {first, map, switchMap} from 'rxjs/operators';
|
import {first, map, switchMap} from 'rxjs/operators';
|
||||||
|
import {startWith} from 'rxjs';
|
||||||
import {KEYS} from '../../../services/key.helper';
|
import {KEYS} from '../../../services/key.helper';
|
||||||
import {COMMA, ENTER} from '@angular/cdk/keycodes';
|
import {COMMA, ENTER} from '@angular/cdk/keycodes';
|
||||||
import {MatChipGrid, MatChipInput, MatChipInputEvent, MatChipRow} from '@angular/material/chips';
|
import {MatChipGrid, MatChipInput, MatChipInputEvent, MatChipRow} from '@angular/material/chips';
|
||||||
import {faExternalLinkAlt, faSave, faTimesCircle} from '@fortawesome/free-solid-svg-icons';
|
import {faExternalLinkAlt, faSave, faTimesCircle} from '@fortawesome/free-solid-svg-icons';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {SaveDialogComponent} from './save-dialog/save-dialog.component';
|
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 {CardComponent} from '../../../../../widget-modules/components/card/card.component';
|
||||||
import {MatFormField, MatLabel, MatSuffix} from '@angular/material/form-field';
|
import {MatFormField, MatLabel, MatSuffix} from '@angular/material/form-field';
|
||||||
@@ -63,6 +66,7 @@ export class EditSongComponent implements OnInit {
|
|||||||
private songService = inject(SongService);
|
private songService = inject(SongService);
|
||||||
private editService = inject(EditService);
|
private editService = inject(EditService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private textRenderingService = inject(TextRenderingService);
|
||||||
public dialog = inject(MatDialog);
|
public dialog = inject(MatDialog);
|
||||||
|
|
||||||
public song: Song | null = null;
|
public song: Song | null = null;
|
||||||
@@ -78,6 +82,7 @@ export class EditSongComponent implements OnInit {
|
|||||||
public faSave = faSave;
|
public faSave = faSave;
|
||||||
public faLink = faExternalLinkAlt;
|
public faLink = faExternalLinkAlt;
|
||||||
public songtextFocus = false;
|
public songtextFocus = false;
|
||||||
|
public chordValidationIssues: ChordValidationIssue[] = [];
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
this.activatedRoute.params
|
this.activatedRoute.params
|
||||||
@@ -92,12 +97,15 @@ export class EditSongComponent implements OnInit {
|
|||||||
if (!song) return;
|
if (!song) return;
|
||||||
this.form = this.editService.createSongForm(song);
|
this.form = this.editService.createSongForm(song);
|
||||||
this.form.controls.flags.valueChanges.subscribe(_ => this.onFlagsChanged(_ as string));
|
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);
|
this.onFlagsChanged(this.form.controls.flags.value as string);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onSave(): Promise<void> {
|
public async onSave(): Promise<void> {
|
||||||
if (!this.song) return;
|
if (!this.song || this.form.invalid) return;
|
||||||
const data = this.form.value as Partial<Song>;
|
const data = this.form.value as Partial<Song>;
|
||||||
await this.songService.update$(this.song.id, data);
|
await this.songService.update$(this.song.id, data);
|
||||||
this.form.markAsPristine();
|
this.form.markAsPristine();
|
||||||
@@ -149,8 +157,23 @@ export class EditSongComponent implements OnInit {
|
|||||||
this.flags = flagArray.split(';').filter(_ => !!_);
|
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) {
|
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<Song>;
|
const data = this.form.value as Partial<Song>;
|
||||||
await this.songService.update$(this.song.id, data);
|
await this.songService.update$(this.song.id, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<button mat-button>
|
<button [disabled]="disabled" mat-button>
|
||||||
@if (icon) {
|
@if (icon) {
|
||||||
<span
|
<span
|
||||||
><fa-icon [icon]="icon"></fa-icon><span class="content"> </span></span
|
><fa-icon [icon]="icon"></fa-icon><span class="content"> </span></span
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
|||||||
imports: [MatButton, FaIconComponent],
|
imports: [MatButton, FaIconComponent],
|
||||||
})
|
})
|
||||||
export class ButtonComponent {
|
export class ButtonComponent {
|
||||||
|
@Input() public disabled = false;
|
||||||
@Input() public icon: IconProp | null = null;
|
@Input() public icon: IconProp | null = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
[class.comment]="isComment(line.text)"
|
[class.comment]="isComment(line.text)"
|
||||||
[class.disabled]="checkDisabled(i)"
|
[class.disabled]="checkDisabled(i)"
|
||||||
class="line"
|
class="line"
|
||||||
>{{ transform(line.text) }}
|
>
|
||||||
|
@for (segment of getDisplaySegments(line); track $index) {
|
||||||
|
<span [class.invalid-chord-token]="segment.invalid">{{ segment.text }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +75,10 @@
|
|||||||
[class.chord]="line.type === 0"
|
[class.chord]="line.type === 0"
|
||||||
[class.disabled]="checkDisabled(i)"
|
[class.disabled]="checkDisabled(i)"
|
||||||
class="line"
|
class="line"
|
||||||
>{{ line.text.trim() }}
|
>
|
||||||
|
@for (segment of getDisplaySegments(line, true); track $index) {
|
||||||
|
<span [class.invalid-chord-token]="segment.invalid">{{ segment.text }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invalid-chord-token {
|
||||||
|
color: #b42318;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -10px;
|
right: -10px;
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import {SectionType} from '../../../modules/songs/services/section-type';
|
|||||||
import {LineType} from '../../../modules/songs/services/line-type';
|
import {LineType} from '../../../modules/songs/services/line-type';
|
||||||
import {Section} from '../../../modules/songs/services/section';
|
import {Section} from '../../../modules/songs/services/section';
|
||||||
import {Line} from '../../../modules/songs/services/line';
|
import {Line} from '../../../modules/songs/services/line';
|
||||||
|
import {ChordValidationIssue} from '../../../modules/songs/services/chord';
|
||||||
|
|
||||||
import {MatIconButton} from '@angular/material/button';
|
import {MatIconButton} from '@angular/material/button';
|
||||||
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
export type ChordMode = 'show' | 'hide' | 'onlyFirst';
|
export type ChordMode = 'show' | 'hide' | 'onlyFirst';
|
||||||
|
|
||||||
|
interface DisplaySegment {
|
||||||
|
text: string;
|
||||||
|
invalid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-song-text',
|
selector: 'app-song-text',
|
||||||
templateUrl: './song-text.component.html',
|
templateUrl: './song-text.component.html',
|
||||||
@@ -36,6 +42,7 @@ export class SongTextComponent implements OnInit {
|
|||||||
public faLines = faGripLines;
|
public faLines = faGripLines;
|
||||||
public offset = 0;
|
public offset = 0;
|
||||||
public iChordMode: ChordMode = 'hide';
|
public iChordMode: ChordMode = 'hide';
|
||||||
|
private invalidChordIssuesByLine = new Map<number, ChordValidationIssue[]>();
|
||||||
private iText = '';
|
private iText = '';
|
||||||
private iTranspose: TransposeMode | null = null;
|
private iTranspose: TransposeMode | null = null;
|
||||||
|
|
||||||
@@ -56,6 +63,18 @@ export class SongTextComponent implements OnInit {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
this.cRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!this.fullscreen || this.index === -1 || !this.viewSections?.toArray()[this.index]) {
|
if (!this.fullscreen || this.index === -1 || !this.viewSections?.toArray()[this.index]) {
|
||||||
@@ -106,6 +125,50 @@ export class SongTextComponent implements OnInit {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDisplaySegments(line: Line, trim = false): DisplaySegment[] {
|
||||||
|
const text = trim ? line.text.trim() : this.transform(line.text);
|
||||||
|
const lineNumber = line.lineNumber;
|
||||||
|
if (!lineNumber) {
|
||||||
|
return [{text, invalid: false}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = this.invalidChordIssuesByLine.get(lineNumber);
|
||||||
|
if (!issues || issues.length === 0) {
|
||||||
|
return [{text, invalid: false}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges: Array<{start: number; end: number}> = [];
|
||||||
|
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});
|
||||||
|
searchStart = index + issue.token.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
return [{text, invalid: 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(range.start, range.end), invalid: true});
|
||||||
|
cursor = range.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
segments.push({text: text.slice(cursor), invalid: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
public isComment(text: string) {
|
public isComment(text: string) {
|
||||||
return text.startsWith('#');
|
return text.startsWith('#');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user