This commit is contained in:
@@ -4,7 +4,7 @@ import {map} from 'rxjs/operators';
|
|||||||
import {combineLatest, Observable} from 'rxjs';
|
import {combineLatest, Observable} from 'rxjs';
|
||||||
import {fade} from '../../../animations';
|
import {fade} from '../../../animations';
|
||||||
import {ActivatedRoute, RouterLink} from '@angular/router';
|
import {ActivatedRoute, RouterLink} from '@angular/router';
|
||||||
import {createSongFilter} from '../../../services/filter.helper';
|
import {searchSongs} from '../../../services/filter.helper';
|
||||||
import {FilterValues} from './filter/filter-values';
|
import {FilterValues} from './filter/filter-values';
|
||||||
import {faBalanceScaleRight, faCheck, faPencilRuler, faPlus} from '@fortawesome/free-solid-svg-icons';
|
import {faBalanceScaleRight, faCheck, faPencilRuler, faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||||
import {TextRenderingService} from '../services/text-rendering.service';
|
import {TextRenderingService} from '../services/text-rendering.service';
|
||||||
@@ -42,22 +42,19 @@ export class SongListComponent {
|
|||||||
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
|
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(([filter, songs]) => {
|
map(([filter, songs]) => {
|
||||||
const matchesSongFilter = createSongFilter(filter.q);
|
return searchSongs(songs, filter.q)
|
||||||
return songs
|
.filter(song => this.filter(song, filter))
|
||||||
.filter(song => this.filter(song, filter, matchesSongFilter))
|
|
||||||
.map(song => ({
|
.map(song => ({
|
||||||
...song,
|
...song,
|
||||||
hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0,
|
hasChordValidationIssues: this.textRenderingService.validateChordNotation(song.text ?? '').length > 0,
|
||||||
}))
|
}));
|
||||||
.sort((a, b) => a.title?.localeCompare(b.title));
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
public trackBy = (index: number, show: SongListItem) => show.id;
|
public trackBy = (index: number, show: SongListItem) => show.id;
|
||||||
|
|
||||||
private filter(song: Song, filter: FilterValues, matchesSongFilter: (song: Song) => boolean): boolean {
|
private filter(song: Song, filter: FilterValues): boolean {
|
||||||
let baseFilter = matchesSongFilter(song);
|
let baseFilter = !filter.type || filter.type === song.type;
|
||||||
baseFilter = baseFilter && (!filter.type || filter.type === song.type);
|
|
||||||
baseFilter = baseFilter && (!filter.key || filter.key === song.key);
|
baseFilter = baseFilter && (!filter.key || filter.key === song.key);
|
||||||
baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType);
|
baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType);
|
||||||
baseFilter = baseFilter && (!filter.flag || this.checkFlag(filter.flag, song.flags));
|
baseFilter = baseFilter && (!filter.flag || this.checkFlag(filter.flag, song.flags));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Song} from '../modules/songs/services/song';
|
import {Song} from '../modules/songs/services/song';
|
||||||
import {filterSong} from './filter.helper';
|
import {createSongScorer, filterSong} from './filter.helper';
|
||||||
|
|
||||||
describe('Filter Helper', () => {
|
describe('Filter Helper', () => {
|
||||||
const song: Song = {
|
const song: Song = {
|
||||||
@@ -87,4 +87,12 @@ describe('Filter Helper', () => {
|
|||||||
it('should not find unrelated shortened wording', () => {
|
it('should not find unrelated shortened wording', () => {
|
||||||
void expect(filterSong({...song, title: 'Heilig Geist'}, 'Heiliger Geist')).toBe(false);
|
void expect(filterSong({...song, title: 'Heilig Geist'}, 'Heiliger Geist')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should rank exact title matches above text matches', () => {
|
||||||
|
const scoreSong = createSongScorer('Heiliger Geist');
|
||||||
|
const titleMatch = scoreSong({...song, title: 'Heiliger Geist', text: 'anderer Text'});
|
||||||
|
const textMatch = scoreSong({...song, title: 'anderer Titel', text: 'Komm Heiliger Geist in diese Stadt'});
|
||||||
|
|
||||||
|
void expect(titleMatch).toBeGreaterThan(textMatch);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
import {Song} from '../modules/songs/services/song';
|
import {Song} from '../modules/songs/services/song';
|
||||||
|
|
||||||
export function filterSong(song: Song, filterValue: string): boolean {
|
export function filterSong(song: Song, filterValue: string): boolean {
|
||||||
return createSongFilter(filterValue)(song);
|
return scoreSongMatch(song, filterValue) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSongFilter(filterValue: string): (song: Song) => boolean {
|
export function createSongFilter(filterValue: string): (song: Song) => boolean {
|
||||||
if (!filterValue) return () => true;
|
const scorer = createSongScorer(filterValue);
|
||||||
|
return (song: Song) => scorer(song) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSongs<T extends Song>(songs: T[], filterValue: string): T[] {
|
||||||
|
const matchesSongFilter = createSongFilter(filterValue);
|
||||||
|
const compareSongs = createSongSearchComparator(filterValue);
|
||||||
|
return songs.filter(matchesSongFilter).sort(compareSongs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreSongMatch(song: Song, filterValue: string): number {
|
||||||
|
return createSongScorer(filterValue)(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSongScorer(filterValue: string): (song: Song) => number {
|
||||||
|
if (!filterValue) return () => 1;
|
||||||
|
|
||||||
const filter = analyzeSearchText(filterValue);
|
const filter = analyzeSearchText(filterValue);
|
||||||
return (song: Song) => {
|
return (song: Song) => {
|
||||||
const searchableSong = getSearchableSong(song);
|
const searchableSong = getSearchableSong(song);
|
||||||
return matchesAnalysis(searchableSong.text, filter) || matchesAnalysis(searchableSong.title, filter) || matchesAnalysis(searchableSong.artist, filter);
|
const titleScore = scoreAnalysis(searchableSong.title, filter, 1000);
|
||||||
|
const artistScore = scoreAnalysis(searchableSong.artist, filter, 700);
|
||||||
|
const textScore = scoreAnalysis(searchableSong.text, filter, 400);
|
||||||
|
return Math.max(titleScore, artistScore, textScore);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSongSearchComparator(filterValue: string): (a: Song, b: Song) => number {
|
||||||
|
const scoreSong = createSongScorer(filterValue);
|
||||||
|
return (a: Song, b: Song) => {
|
||||||
|
if (filterValue) {
|
||||||
|
const scoreDiff = scoreSong(b) - scoreSong(a);
|
||||||
|
if (scoreDiff !== 0) return scoreDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleCompare = (a.title ?? '').localeCompare(b.title ?? '');
|
||||||
|
if (titleCompare !== 0) return titleCompare;
|
||||||
|
|
||||||
|
return a.number - b.number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +60,19 @@ type SearchableSong = {
|
|||||||
|
|
||||||
const searchableSongCache = new WeakMap<Song, SearchableSong>();
|
const searchableSongCache = new WeakMap<Song, SearchableSong>();
|
||||||
|
|
||||||
function matchesAnalysis(haystack: SearchAnalysis | undefined, filter: SearchAnalysis): boolean {
|
function scoreAnalysis(haystack: SearchAnalysis | undefined, filter: SearchAnalysis, fieldWeight: number): number {
|
||||||
if (!haystack) return false;
|
if (!haystack || !filter.compact) return 0;
|
||||||
|
|
||||||
return haystack.compact.includes(filter.compact) || matchesTokenSequence(haystack.tokens, filter.tokens);
|
const compactIndex = haystack.compact.indexOf(filter.compact);
|
||||||
|
const tokenIndex = findTokenSequenceIndex(haystack.tokens, filter.tokens);
|
||||||
|
|
||||||
|
if (compactIndex === -1 && tokenIndex === -1) return 0;
|
||||||
|
|
||||||
|
if (haystack.compact === filter.compact) return fieldWeight + 500;
|
||||||
|
if (tokenIndex === 0) return fieldWeight + 350;
|
||||||
|
if (compactIndex === 0) return fieldWeight + 250;
|
||||||
|
if (tokenIndex !== -1) return fieldWeight + 150;
|
||||||
|
return fieldWeight + 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeSearchText(input: string): SearchAnalysis {
|
function analyzeSearchText(input: string): SearchAnalysis {
|
||||||
@@ -41,15 +83,15 @@ function analyzeSearchText(input: string): SearchAnalysis {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTokenSequence(haystackTokens: string[], needleTokens: string[]): boolean {
|
function findTokenSequenceIndex(haystackTokens: string[], needleTokens: string[]): number {
|
||||||
if (needleTokens.length === 0 || haystackTokens.length < needleTokens.length) return false;
|
if (needleTokens.length === 0 || haystackTokens.length < needleTokens.length) return -1;
|
||||||
|
|
||||||
for (let start = 0; start <= haystackTokens.length - needleTokens.length; start++) {
|
for (let start = 0; start <= haystackTokens.length - needleTokens.length; start++) {
|
||||||
const matches = needleTokens.every((needleToken, index) => haystackTokens[start + index] === needleToken);
|
const matches = needleTokens.every((needleToken, index) => haystackTokens[start + index] === needleToken);
|
||||||
if (matches) return true;
|
if (matches) return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenizeForSearch(input: string): string[] {
|
function tokenizeForSearch(input: string): string[] {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, inject} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, inject} from '@angular/core';
|
||||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||||
import {FormControl, ReactiveFormsModule} from '@angular/forms';
|
import {FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||||
import {createSongFilter} from '../../../services/filter.helper';
|
import {searchSongs} from '../../../services/filter.helper';
|
||||||
import {MatFormField, MatLabel, MatOption, MatSelect, MatSelectChange} from '@angular/material/select';
|
import {MatFormField, MatLabel, MatOption, MatSelect, MatSelectChange} from '@angular/material/select';
|
||||||
import {Song} from '../../../modules/songs/services/song';
|
import {Song} from '../../../modules/songs/services/song';
|
||||||
import {ShowSong} from '../../../modules/shows/services/show-song';
|
import {ShowSong} from '../../../modules/shows/services/show-song';
|
||||||
@@ -56,10 +56,10 @@ export class AddSongComponent {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterValue = this.debouncedFilterValue;
|
const filterValue = this.debouncedFilterValue;
|
||||||
return filterValue ? songs.filter(createSongFilter(filterValue)) : songs;
|
return filterValue ? searchSongs(songs, filterValue) : songs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async onAddSongSelectionChanged(event: MatSelectChange): Promise<void> {
|
public async onAddSongSelectionChanged(event: MatSelectChange): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user