This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {Component, DestroyRef, Input, inject} from '@angular/core';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
|
||||
import {SongService} from '../../services/song.service';
|
||||
import {FilterValues} from './filter-values';
|
||||
import {Song} from '../../services/song';
|
||||
@@ -52,7 +53,9 @@ export class FilterComponent {
|
||||
this.filterFormGroup.patchValue(filterValues, {emitEvent: false});
|
||||
});
|
||||
|
||||
this.filterFormGroup.controls.q.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('q', value));
|
||||
this.filterFormGroup.controls.q.valueChanges
|
||||
.pipe(debounceTime(100), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(value => this.filterValueChanged('q', value));
|
||||
this.filterFormGroup.controls.key.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('key', value));
|
||||
this.filterFormGroup.controls.type.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('type', value));
|
||||
this.filterFormGroup.controls.legalType.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('legalType', value));
|
||||
|
||||
@@ -4,7 +4,7 @@ import {map} from 'rxjs/operators';
|
||||
import {combineLatest, Observable} from 'rxjs';
|
||||
import {fade} from '../../../animations';
|
||||
import {ActivatedRoute, RouterLink} from '@angular/router';
|
||||
import {filterSong} from '../../../services/filter.helper';
|
||||
import {createSongFilter} from '../../../services/filter.helper';
|
||||
import {FilterValues} from './filter/filter-values';
|
||||
import {faBalanceScaleRight, faCheck, faPencilRuler, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {TextRenderingService} from '../services/text-rendering.service';
|
||||
@@ -42,8 +42,9 @@ export class SongListComponent {
|
||||
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
|
||||
]).pipe(
|
||||
map(([filter, songs]) => {
|
||||
const matchesSongFilter = createSongFilter(filter.q);
|
||||
return songs
|
||||
.filter(song => this.filter(song, filter))
|
||||
.filter(song => this.filter(song, filter, matchesSongFilter))
|
||||
.map(song => ({
|
||||
...song,
|
||||
hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0,
|
||||
@@ -54,8 +55,8 @@ export class SongListComponent {
|
||||
|
||||
public trackBy = (index: number, show: SongListItem) => show.id;
|
||||
|
||||
private filter(song: Song, filter: FilterValues): boolean {
|
||||
let baseFilter = filterSong(song, filter.q);
|
||||
private filter(song: Song, filter: FilterValues, matchesSongFilter: (song: Song) => boolean): boolean {
|
||||
let baseFilter = matchesSongFilter(song);
|
||||
baseFilter = baseFilter && (!filter.type || filter.type === song.type);
|
||||
baseFilter = baseFilter && (!filter.key || filter.key === song.key);
|
||||
baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType);
|
||||
|
||||
@@ -63,4 +63,28 @@ describe('Filter Helper', () => {
|
||||
it('should find apostroph invariant', () => {
|
||||
void expect(filterSong(song, 'eeff')).toBe(true);
|
||||
});
|
||||
|
||||
it('should find contracted title when searching full adjective', () => {
|
||||
void expect(filterSong({...song, title: "Heil'ger Geist"}, 'Heiliger Geist')).toBe(true);
|
||||
});
|
||||
|
||||
it('should find shortened title without apostrophe when searching full adjective', () => {
|
||||
void expect(filterSong({...song, title: 'Heilger Geist'}, 'Heiliger Geist')).toBe(true);
|
||||
});
|
||||
|
||||
it('should find full adjective when searching shortened title', () => {
|
||||
void expect(filterSong({...song, title: 'Heiliger Geist'}, 'Heilger Geist')).toBe(true);
|
||||
});
|
||||
|
||||
it('should find contracted accusative form when searching full wording', () => {
|
||||
void expect(filterSong({...song, text: "Komm, du ew'gen Geist"}, 'ewigen Geist')).toBe(true);
|
||||
});
|
||||
|
||||
it('should find full wording when searching contracted accusative form', () => {
|
||||
void expect(filterSong({...song, text: 'Komm, du ewigen Geist'}, "ew'gen Geist")).toBe(true);
|
||||
});
|
||||
|
||||
it('should not find unrelated shortened wording', () => {
|
||||
void expect(filterSong({...song, title: 'Heilig Geist'}, 'Heiliger Geist')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,83 @@
|
||||
import {Song} from '../modules/songs/services/song';
|
||||
|
||||
export function filterSong(song: Song, filterValue: string): boolean {
|
||||
if (!filterValue) return true;
|
||||
|
||||
const textMatch = !!song.text && normalize(song.text)?.indexOf(normalize(filterValue)) !== -1;
|
||||
const titleMatch = !!song.title && normalize(song.title)?.indexOf(normalize(filterValue)) !== -1;
|
||||
const artistMatch = !!song.title && normalize(song.artist)?.indexOf(normalize(filterValue)) !== -1;
|
||||
|
||||
return textMatch || titleMatch || artistMatch;
|
||||
return createSongFilter(filterValue)(song);
|
||||
}
|
||||
|
||||
function normalize(input: string): string {
|
||||
return input?.toLowerCase().replace(/[\s?!.,']/g, '');
|
||||
export function createSongFilter(filterValue: string): (song: Song) => boolean {
|
||||
if (!filterValue) return () => true;
|
||||
|
||||
const filter = analyzeSearchText(filterValue);
|
||||
return (song: Song) => {
|
||||
const searchableSong = getSearchableSong(song);
|
||||
return matchesAnalysis(searchableSong.text, filter) || matchesAnalysis(searchableSong.title, filter) || matchesAnalysis(searchableSong.artist, filter);
|
||||
};
|
||||
}
|
||||
|
||||
type SearchAnalysis = {
|
||||
compact: string,
|
||||
tokens: string[],
|
||||
};
|
||||
|
||||
type SearchableSong = {
|
||||
text?: SearchAnalysis,
|
||||
title?: SearchAnalysis,
|
||||
artist?: SearchAnalysis,
|
||||
};
|
||||
|
||||
const searchableSongCache = new WeakMap<Song, SearchableSong>();
|
||||
|
||||
function matchesAnalysis(haystack: SearchAnalysis | undefined, filter: SearchAnalysis): boolean {
|
||||
if (!haystack) return false;
|
||||
|
||||
return haystack.compact.includes(filter.compact) || matchesTokenSequence(haystack.tokens, filter.tokens);
|
||||
}
|
||||
|
||||
function analyzeSearchText(input: string): SearchAnalysis {
|
||||
const tokens = tokenizeForSearch(input);
|
||||
return {
|
||||
compact: tokens.join(''),
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesTokenSequence(haystackTokens: string[], needleTokens: string[]): boolean {
|
||||
if (needleTokens.length === 0 || haystackTokens.length < needleTokens.length) return false;
|
||||
|
||||
for (let start = 0; start <= haystackTokens.length - needleTokens.length; start++) {
|
||||
const matches = needleTokens.every((needleToken, index) => haystackTokens[start + index] === needleToken);
|
||||
if (matches) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function tokenizeForSearch(input: string): string[] {
|
||||
return input
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/\p{M}/gu, '')
|
||||
.split(/[^\p{L}\p{N}']+/gu)
|
||||
.map(normalizeTokenForSearch)
|
||||
.filter(token => !!token);
|
||||
}
|
||||
|
||||
function normalizeTokenForSearch(token: string): string {
|
||||
const cleaned = token.replace(/'/g, '');
|
||||
return cleaned.replace(/ig(e|em|en|er|es)?$/u, 'g$1');
|
||||
}
|
||||
|
||||
function getSearchableSong(song: Song): SearchableSong {
|
||||
const cached = searchableSongCache.get(song);
|
||||
if (cached) return cached;
|
||||
|
||||
const searchableSong: SearchableSong = {
|
||||
text: song.text ? analyzeSearchText(song.text) : undefined,
|
||||
title: song.title ? analyzeSearchText(song.title) : undefined,
|
||||
artist: song.artist ? analyzeSearchText(song.artist) : undefined,
|
||||
};
|
||||
searchableSongCache.set(song, searchableSong);
|
||||
return searchableSong;
|
||||
}
|
||||
|
||||
export const onlyUnique = <T>(value: T, index: number, array: T[]) => array.indexOf(value) === index;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ChangeDetectionStrategy, Component, Input, inject} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, inject} from '@angular/core';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||
import {filterSong} from '../../../services/filter.helper';
|
||||
import {createSongFilter} from '../../../services/filter.helper';
|
||||
import {MatFormField, MatLabel, MatOption, MatSelect, MatSelectChange} from '@angular/material/select';
|
||||
import {Song} from '../../../modules/songs/services/song';
|
||||
import {ShowSong} from '../../../modules/shows/services/show-song';
|
||||
@@ -9,6 +10,7 @@ import {Show} from '../../../modules/shows/services/show';
|
||||
import {ShowService} from '../../../modules/shows/services/show.service';
|
||||
|
||||
import {NgxMatSelectSearchModule} from 'ngx-mat-select-search';
|
||||
import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-song',
|
||||
@@ -20,12 +22,24 @@ import {NgxMatSelectSearchModule} from 'ngx-mat-select-search';
|
||||
export class AddSongComponent {
|
||||
private showSongService = inject(ShowSongService);
|
||||
private showService = inject(ShowService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private cRef = inject(ChangeDetectorRef);
|
||||
|
||||
@Input() public songs: Song[] | null = null;
|
||||
@Input() public showSongs: ShowSong[] | null = null;
|
||||
@Input() public show: Show | null = null;
|
||||
@Input() public addedLive = false;
|
||||
public filteredSongsControl = new FormControl<string>('', {nonNullable: true});
|
||||
public debouncedFilterValue = '';
|
||||
|
||||
public constructor() {
|
||||
this.filteredSongsControl.valueChanges
|
||||
.pipe(startWith(this.filteredSongsControl.value), debounceTime(100), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(value => {
|
||||
this.debouncedFilterValue = value;
|
||||
this.cRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public filteredSongs(): Song[] {
|
||||
if (!this.songs) return [];
|
||||
@@ -44,8 +58,8 @@ export class AddSongComponent {
|
||||
return 0;
|
||||
});
|
||||
|
||||
const filterValue = this.filteredSongsControl.value;
|
||||
return filterValue ? songs.filter(_ => filterSong(_, filterValue)) : songs;
|
||||
const filterValue = this.debouncedFilterValue;
|
||||
return filterValue ? songs.filter(createSongFilter(filterValue)) : songs;
|
||||
}
|
||||
|
||||
public async onAddSongSelectionChanged(event: MatSelectChange): Promise<void> {
|
||||
|
||||
@@ -3,6 +3,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {ActivatedRoute, Params, Router} from '@angular/router';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {FilterStoreService} from '../../../../../services/filter-store.service';
|
||||
import {Subject} from 'rxjs';
|
||||
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter',
|
||||
@@ -14,6 +16,7 @@ export class FilterComponent {
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private filterStore = inject(FilterStoreService);
|
||||
private valueChanged$ = new Subject<string>();
|
||||
|
||||
public value = '';
|
||||
|
||||
@@ -25,9 +28,17 @@ export class FilterComponent {
|
||||
this.value = typedParams.q ?? '';
|
||||
this.filterStore.updateSongFilter({q: this.value});
|
||||
});
|
||||
|
||||
this.valueChanged$
|
||||
.pipe(debounceTime(100), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(text => void this.applyValueChange(text));
|
||||
}
|
||||
|
||||
public async valueChange(text: string): Promise<void> {
|
||||
public valueChange(text: string): void {
|
||||
this.valueChanged$.next(text);
|
||||
}
|
||||
|
||||
private async applyValueChange(text: string): Promise<void> {
|
||||
this.filterStore.updateSongFilter({q: text});
|
||||
const route = this.router.createUrlTree(['songs'], {
|
||||
queryParams: {q: text},
|
||||
|
||||
Reference in New Issue
Block a user