transpose & history

This commit is contained in:
2020-06-13 17:41:53 +02:00
parent bcbd119fbd
commit 835ffa9e8e
19 changed files with 256 additions and 23 deletions

View File

@@ -51,7 +51,7 @@ export class MonitorComponent implements OnInit {
switchMap(_ => this.songService.read$(_.presentationSongId)) switchMap(_ => this.songService.read$(_.presentationSongId))
).subscribe((_: Song) => { ).subscribe((_: Song) => {
this.song = _; this.song = _;
this.sections = this.textRenderingService.parse(_.text); this.sections = this.textRenderingService.parse(_.text, null);
}); });
} }

View File

@@ -75,7 +75,7 @@ export class RemoteComponent {
.map(song => ({ .map(song => ({
id: song.id, id: song.id,
title: song.title, title: song.title,
sections: this.textRenderingService.parse(song.text) sections: this.textRenderingService.parse(song.text, null)
})) }))
}); });
await delay(500); await delay(500);

View File

@@ -180,7 +180,10 @@ export class DocxService {
const showSongs = await this.showSongService.list(showId); const showSongs = await this.showSongService.list(showId);
const songsAsync = await showSongs.map(async showSong => { const songsAsync = await showSongs.map(async showSong => {
const song = await this.songService.read(showSong.songId); const song = await this.songService.read(showSong.songId);
const sections = this.textRenderingService.parse(song.text); const sections = this.textRenderingService.parse(song.text, {
baseKey: showSong.keyOriginal,
targetKey: showSong.key
});
return { return {
showSong, showSong,
song, song,

View File

@@ -16,6 +16,6 @@
<app-menu-button (click)="onDelete()" [icon]="faDelete" class="btn-delete btn-icon"></app-menu-button> <app-menu-button (click)="onDelete()" [icon]="faDelete" class="btn-delete btn-icon"></app-menu-button>
</div> </div>
<app-song-text (chordModeChanged)="onChordModeChanged($event)" *ngIf="showText || show.published" <app-song-text (chordModeChanged)="onChordModeChanged($event)" *ngIf="showText || show.published"
[chordMode]="showSong.chordMode" [chordMode]="showSong.chordMode" [transpose]="{baseKey: showSong.keyOriginal, targetKey: showSong.key}"
[showSwitch]="!show.published" [text]="_song.text"></app-song-text> [showSwitch]="!show.published" [text]="_song.text"></app-song-text>
</div> </div>

View File

@@ -2,7 +2,6 @@ export const KEYS = [
'C#', 'C', 'Db', 'D#', 'D', 'Eb', 'E', 'F#', 'F', 'Gb', 'G#', 'G', 'Ab', 'A#', 'A', 'B', 'H', 'C#', 'C', 'Db', 'D#', 'D', 'Eb', 'E', 'F#', 'F', 'Gb', 'G#', 'G', 'Ab', 'A#', 'A', 'B', 'H',
'c#', 'c', 'db', 'd#', 'd', 'eb', 'e', 'f#', 'f', 'gb', 'g#', 'g', 'ab', 'a#', 'a', 'b', 'h' 'c#', 'c', 'db', 'd#', 'd', 'eb', 'e', 'f#', 'f', 'gb', 'g#', 'g', 'ab', 'a#', 'a', 'b', 'h'
]; ];
export const KEYS_REGEX = new RegExp('(' + KEYS.reduce((a, b) => a + '|' + b) + ')', 'gm');
export const KEYS_MAJOR_FLAT = [ export const KEYS_MAJOR_FLAT = [
'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'H', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'H',
]; ];
@@ -16,6 +15,45 @@ export const KEYS_MINOR_B = [
'c', 'db', 'd', 'eb', 'e', 'f', 'gb', 'g', 'ab', 'a', 'b', 'h', 'c', 'db', 'd', 'eb', 'e', 'f', 'gb', 'g', 'ab', 'a', 'b', 'h',
]; ];
export type scale = 'b' | 'flat'
const scaleTypeAssignment: { [key: string]: string[][] } = {
'C': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'C#': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'Db': [KEYS_MAJOR_B, KEYS_MINOR_B],
'D': [KEYS_MAJOR_B, KEYS_MINOR_B],
'D#': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'Eb': [KEYS_MAJOR_B, KEYS_MINOR_B],
'E': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'F': [KEYS_MAJOR_B, KEYS_MINOR_B],
'F#': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'Gb': [KEYS_MAJOR_B, KEYS_MINOR_B],
'G': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'G#': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'Ab': [KEYS_MAJOR_B, KEYS_MINOR_B],
'A': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'A#': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'B': [KEYS_MAJOR_B, KEYS_MINOR_B],
'H': [KEYS_MAJOR_FLAT, KEYS_MINOR_FLAT],
'c': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'c#': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'db': [KEYS_MINOR_B, KEYS_MAJOR_B],
'd': [KEYS_MINOR_B, KEYS_MAJOR_B],
'd#': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'eb': [KEYS_MINOR_B, KEYS_MAJOR_B],
'e': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'f': [KEYS_MINOR_B, KEYS_MAJOR_B],
'f#': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'gb': [KEYS_MINOR_B, KEYS_MAJOR_B],
'g': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'g#': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'ab': [KEYS_MINOR_B, KEYS_MAJOR_B],
'a': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'a#': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
'b': [KEYS_MINOR_B, KEYS_MAJOR_B],
'h': [KEYS_MINOR_FLAT, KEYS_MAJOR_FLAT],
}
const scaleAssignment = { const scaleAssignment = {
'C': KEYS_MAJOR_FLAT, 'C': KEYS_MAJOR_FLAT,
'C#': KEYS_MAJOR_FLAT, 'C#': KEYS_MAJOR_FLAT,
@@ -90,7 +128,6 @@ export const scaleMapping = {
'h': 'h', 'h': 'h',
}; };
export const getScale = (key: string): string[] => { export const getScale = (key: string): string[] => scaleAssignment[key];
const scaleAssignmentElement = scaleAssignment[key]; export const getScaleType = (key: string): string[][] => scaleTypeAssignment[key];
return scaleAssignmentElement;
};

View File

@@ -3,6 +3,9 @@ import {Observable} from 'rxjs';
import {Song} from './song'; import {Song} from './song';
import {SongDataService} from './song-data.service'; import {SongDataService} from './song-data.service';
import {first, tap} from 'rxjs/operators'; import {first, tap} from 'rxjs/operators';
import {UserService} from '../../../services/user/user.service';
import * as firebase from 'firebase';
import Timestamp = firebase.firestore.Timestamp;
declare var importCCLI: any; declare var importCCLI: any;
@@ -19,7 +22,7 @@ export class SongService {
private list: Song[]; private list: Song[];
constructor(private songDataService: SongDataService) { constructor(private songDataService: SongDataService, private userService: UserService) {
importCCLI = (songs: Song[]) => this.updateFromCLI(songs); importCCLI = (songs: Song[]) => this.updateFromCLI(songs);
} }
@@ -28,7 +31,11 @@ export class SongService {
public read = (songId: string): Promise<Song | undefined> => this.read$(songId).pipe(first()).toPromise(); public read = (songId: string): Promise<Song | undefined> => this.read$(songId).pipe(first()).toPromise();
public async update$(songId: string, data: Partial<Song>): Promise<void> { public async update$(songId: string, data: Partial<Song>): Promise<void> {
await this.songDataService.update$(songId, data); const song = await this.read(songId);
const edits = song.edits ?? [];
const user = await this.userService.currentUser();
edits.push({username: user.name, timestamp: Timestamp.now()})
await this.songDataService.update$(songId, {...data, edits});
} }
public async new(number: number, title: string): Promise<string> { public async new(number: number, title: string): Promise<string> {

View File

@@ -1,3 +1,6 @@
import Timestamp = firebase.firestore.Timestamp;
import * as firebase from 'firebase';
export interface Song { export interface Song {
id: string; id: string;
comment: string; comment: string;
@@ -19,4 +22,11 @@ export interface Song {
label: string; label: string;
termsOfUse: string; termsOfUse: string;
origin: string; origin: string;
edits: Edit[];
}
export interface Edit {
username: string;
timestamp: Timestamp;
} }

View File

@@ -33,7 +33,7 @@ Cool bridge without any chords
it('should parse section types', () => { it('should parse section types', () => {
const service: TextRenderingService = TestBed.get(TextRenderingService); const service: TextRenderingService = TestBed.get(TextRenderingService);
const sections = service.parse(testText); const sections = service.parse(testText, null);
expect(sections[0].type).toBe(SectionType.Verse); expect(sections[0].type).toBe(SectionType.Verse);
expect(sections[0].number).toBe(0); expect(sections[0].number).toBe(0);
expect(sections[1].type).toBe(SectionType.Verse); expect(sections[1].type).toBe(SectionType.Verse);
@@ -46,7 +46,7 @@ Cool bridge without any chords
it('should parse text lines', () => { it('should parse text lines', () => {
const service: TextRenderingService = TestBed.get(TextRenderingService); const service: TextRenderingService = TestBed.get(TextRenderingService);
const sections = service.parse(testText); const sections = service.parse(testText, null);
expect(sections[0].lines[1].type).toBe(LineType.text); expect(sections[0].lines[1].type).toBe(LineType.text);
expect(sections[0].lines[1].text).toBe('Text Line 1-1'); expect(sections[0].lines[1].text).toBe('Text Line 1-1');
expect(sections[0].lines[3].type).toBe(LineType.text); expect(sections[0].lines[3].type).toBe(LineType.text);
@@ -63,7 +63,7 @@ Cool bridge without any chords
it('should parse chord lines', () => { it('should parse chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService); const service: TextRenderingService = TestBed.inject(TextRenderingService);
const sections = service.parse(testText); const sections = service.parse(testText, null);
expect(sections[0].lines[0].type).toBe(LineType.chord); expect(sections[0].lines[0].type).toBe(LineType.chord);
expect(sections[0].lines[0].text).toBe('C D E F G A H'); expect(sections[0].lines[0].text).toBe('C D E F G A H');
expect(sections[0].lines[2].type).toBe(LineType.chord); expect(sections[0].lines[2].type).toBe(LineType.chord);
@@ -91,7 +91,7 @@ Cool bridge without any chords
const text = `Strophe const text = `Strophe
g# F# E g# F# E g# F# E g# F# E
text` text`
const sections = service.parse(text); const sections = service.parse(text, null);
expect(sections[0].lines[0].type).toBe(LineType.chord); expect(sections[0].lines[0].type).toBe(LineType.chord);
expect(sections[0].lines[0].text).toBe('g# F# E g# F# E'); expect(sections[0].lines[0].text).toBe('g# F# E g# F# E');
expect(sections[0].lines[1].type).toBe(LineType.text); expect(sections[0].lines[1].type).toBe(LineType.text);

View File

@@ -1,4 +1,5 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {TransposeMode, TransposeService} from './transpose.service';
export enum SectionType { export enum SectionType {
Verse, Verse,
@@ -22,7 +23,7 @@ export interface Chord {
export interface Line { export interface Line {
type: LineType; type: LineType;
text: string; text: string;
chords?: Chord[] chords?: Chord[];
} }
export interface Section { export interface Section {
@@ -37,10 +38,10 @@ export interface Section {
export class TextRenderingService { export class TextRenderingService {
private regexSection = /(Strophe|Refrain|Bridge)/; private regexSection = /(Strophe|Refrain|Bridge)/;
constructor() { constructor(private transposeService: TransposeService) {
} }
public parse(text: string): Section[] { public parse(text: string, transpose: TransposeMode): Section[] {
if (!text) return []; if (!text) return [];
const arrayOfLines = text.split(/\r?\n/).filter(_ => _); const arrayOfLines = text.split(/\r?\n/).filter(_ => _);
const indices = { const indices = {
@@ -55,18 +56,19 @@ export class TextRenderingService {
number: indices[type]++, number: indices[type]++,
lines: [] lines: []
}]; }];
array[array.length - 1].lines.push(this.getLineOfLineText(line)); array[array.length - 1].lines.push(this.getLineOfLineText(line, transpose));
return array; return array;
}, [] as Section[]); }, [] as Section[]);
} }
private getLineOfLineText(text: string): Line { private getLineOfLineText(text: string, transpose: TransposeMode): Line {
if (!text) return null; if (!text) return null;
const cords = this.readChords(text); const cords = this.readChords(text);
const hasMatches = cords.length > 0; const hasMatches = cords.length > 0;
const type = hasMatches ? LineType.chord : LineType.text; const type = hasMatches ? LineType.chord : LineType.text;
return {type, text, chords: hasMatches ? cords : undefined} const line = {type, text, chords: hasMatches ? cords : undefined};
return transpose ? this.transposeService.transpose(line, transpose.baseKey, transpose.targetKey) : line;
} }
private getSectionTypeOfLine(line: string): SectionType { private getSectionTypeOfLine(line: string): SectionType {

View File

@@ -0,0 +1,20 @@
import {TestBed} from '@angular/core/testing';
import {TransposeService} from './transpose.service';
describe('TransposeService', () => {
let service: TransposeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TransposeService);
});
it('should create map', () => {
const distance = service.getDistance('D', 'G');
const map = service.getMap('D', distance);
console.log(map);
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,80 @@
import {Injectable} from '@angular/core';
import {Chord, Line, LineType} from './text-rendering.service';
import {getScaleType, scaleMapping} from './key.helper';
export interface TransposeMode {
baseKey: string;
targetKey: string
}
@Injectable({
providedIn: 'root'
})
export class TransposeService {
public transpose(line: Line, baseKey: string, targetKey: string): Line {
if (line.type !== LineType.chord) return line;
const difference = this.getDistance(baseKey, targetKey);
const map = this.getMap(baseKey, difference);
const chords = line.chords.map(chord => this.transposeChord(chord, map));
const renderedLine = this.renderLine(chords);
return {...line, text: renderedLine, chords};
}
public getDistance(baseKey: string, targetKey: string): number {
const scale = getScaleType(baseKey);
return (
(scale[0].indexOf(targetKey) - scale[0].indexOf(baseKey)) ??
(scale[1].indexOf(targetKey) - scale[1].indexOf(baseKey))
) % 12;
}
public getMap(baseKey: string, difference: number) {
const scale = getScaleType(baseKey);
const map = {};
for (let i = 0; i < 12; i++) {
const source = scale[0][i];
const mappedIndex = (i + difference) % 12;
map[source] = scale[0][mappedIndex];
}
for (let i = 0; i < 12; i++) {
const source = scale[1][i];
const mappedIndex = (i + difference) % 12;
map[source] = scale[1][mappedIndex];
}
return map;
}
private transposeChord(chord: Chord, map: {}): Chord {
const translatedChord = map[chord.chord];
const translatedSlashChord = chord.slashChord ? map[chord.slashChord] : null;
return {...chord, chord: translatedChord, slashChord: translatedSlashChord};
}
private renderLine(chords: Chord[]): string {
let template = ' ';
chords.forEach(chord => {
const pos = chord.position;
const renderedChord = this.renderChord(chord);
const newLength = renderedChord.length;
const pre = template.substr(0, pos);
const post = template.substr(pos + newLength);
template = pre + renderedChord + post;
})
return template.trimRight();
}
private renderChord(chord: Chord) {
return (
scaleMapping[chord.chord] +
(chord.add ? chord.add : '') +
(chord.slashChord ? '/' + scaleMapping[chord.slashChord] : ''));
}
}

View File

@@ -1,4 +1,5 @@
<div> <div>
<app-edit-song></app-edit-song> <app-edit-song></app-edit-song>
<app-edit-file></app-edit-file> <app-edit-file></app-edit-file>
<app-history></app-history>
</div> </div>

View File

@@ -24,10 +24,11 @@ import {ButtonModule} from '../../../../widget-modules/components/button/button.
import {MatTooltipModule} from '@angular/material/tooltip'; import {MatTooltipModule} from '@angular/material/tooltip';
import {SaveDialogComponent} from './edit-song/save-dialog/save-dialog.component'; import {SaveDialogComponent} from './edit-song/save-dialog/save-dialog.component';
import {MatDialogModule} from '@angular/material/dialog'; import {MatDialogModule} from '@angular/material/dialog';
import {HistoryComponent} from './history/history.component';
@NgModule({ @NgModule({
declarations: [EditComponent, EditSongComponent, EditFileComponent, FileComponent, SaveDialogComponent], declarations: [EditComponent, EditSongComponent, EditFileComponent, FileComponent, SaveDialogComponent, HistoryComponent],
exports: [EditComponent], exports: [EditComponent],
bootstrap: [SaveDialogComponent], bootstrap: [SaveDialogComponent],
imports: [ imports: [

View File

@@ -0,0 +1,6 @@
<app-card *ngIf="song && song.edits" heading="letzte Änderungen">
<div *ngFor="let edit of song.edits" class="list">
<div>{{edit.username}}</div>
<div>{{edit.timestamp.toDate()|date:'dd.MM.yyyy'}}</div>
</div>
</app-card>

View File

@@ -0,0 +1,4 @@
.list {
display: grid;
grid-template-columns: 1fr 1fr;
}

View File

@@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {HistoryComponent} from './history.component';
describe('HistoryComponent', () => {
let component: HistoryComponent;
let fixture: ComponentFixture<HistoryComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HistoryComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,31 @@
import {Component, OnInit} from '@angular/core';
import {first, map, switchMap} from 'rxjs/operators';
import {ActivatedRoute} from '@angular/router';
import {SongService} from '../../../services/song.service';
import {Song} from '../../../services/song';
@Component({
selector: 'app-history',
templateUrl: './history.component.html',
styleUrls: ['./history.component.less']
})
export class HistoryComponent implements OnInit {
public song: Song;
constructor(
private activatedRoute: ActivatedRoute,
private songService: SongService,
) {
}
public ngOnInit(): void {
this.activatedRoute.params.pipe(
map(param => param.songId),
switchMap(songId => this.songService.read$(songId)),
first()
).subscribe(song => {
this.song = song;
});
}
}

View File

@@ -31,6 +31,10 @@ export class UserService {
return this._user$.pipe(filter(_ => !!_)); return this._user$.pipe(filter(_ => !!_));
} }
public async currentUser(): Promise<User> {
return this.user$.pipe(first()).toPromise();
}
public getUserbyId(userId: string): Promise<User> { public getUserbyId(userId: string): Promise<User> {
return this.getUserbyId$(userId).pipe(first()).toPromise(); return this.getUserbyId$(userId).pipe(first()).toPromise();
} }

View File

@@ -8,6 +8,7 @@ import {
} from '../../../modules/songs/services/text-rendering.service'; } from '../../../modules/songs/services/text-rendering.service';
import {faGripLines} from '@fortawesome/free-solid-svg-icons/faGripLines'; import {faGripLines} from '@fortawesome/free-solid-svg-icons/faGripLines';
import {songSwitch} from './animation'; import {songSwitch} from './animation';
import {TransposeMode} from '../../../modules/songs/services/transpose.service';
export type ChordMode = 'show' | 'hide' | 'onlyFirst' export type ChordMode = 'show' | 'hide' | 'onlyFirst'
@@ -22,6 +23,7 @@ export class SongTextComponent implements OnInit {
@Input() public index = -1; @Input() public index = -1;
@Input() public fullscreen = false; @Input() public fullscreen = false;
@Input() public showSwitch = false; @Input() public showSwitch = false;
@Input() public transpose: TransposeMode = null;
@Output() public chordModeChanged = new EventEmitter<ChordMode>(); @Output() public chordModeChanged = new EventEmitter<ChordMode>();
@ViewChildren('section') viewSections: QueryList<ElementRef>; @ViewChildren('section') viewSections: QueryList<ElementRef>;
public faLines = faGripLines; public faLines = faGripLines;
@@ -42,7 +44,7 @@ export class SongTextComponent implements OnInit {
this.sections = null; this.sections = null;
this.offset = 0; this.offset = 0;
setTimeout(() => setTimeout(() =>
this.sections = this.textRenderingService.parse(value).sort((a, b) => a.type - b.type), 100); this.sections = this.textRenderingService.parse(value, this.transpose).sort((a, b) => a.type - b.type), 100);
} }