From d484239429333e8d2be57320bda1e212a379192e Mon Sep 17 00:00:00 2001 From: benjamin Date: Fri, 20 Mar 2026 20:51:17 +0100 Subject: [PATCH] tokenized text search #2 --- .../songs/song-list/song-list.component.ts | 15 ++--- src/app/services/filter.helper.spec.ts | 10 ++- src/app/services/filter.helper.ts | 62 ++++++++++++++++--- .../components/add-song/add-song.component.ts | 6 +- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/app/modules/songs/song-list/song-list.component.ts b/src/app/modules/songs/song-list/song-list.component.ts index 04b6576..2cbf11a 100644 --- a/src/app/modules/songs/song-list/song-list.component.ts +++ b/src/app/modules/songs/song-list/song-list.component.ts @@ -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 {createSongFilter} from '../../../services/filter.helper'; +import {searchSongs} 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,22 +42,19 @@ 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, matchesSongFilter)) + return searchSongs(songs, filter.q) + .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 trackBy = (index: number, show: SongListItem) => show.id; - private filter(song: Song, filter: FilterValues, matchesSongFilter: (song: Song) => boolean): boolean { - let baseFilter = matchesSongFilter(song); - baseFilter = baseFilter && (!filter.type || filter.type === song.type); + private filter(song: Song, filter: FilterValues): boolean { + let baseFilter = !filter.type || filter.type === song.type; baseFilter = baseFilter && (!filter.key || filter.key === song.key); baseFilter = baseFilter && (!filter.legalType || filter.legalType === song.legalType); baseFilter = baseFilter && (!filter.flag || this.checkFlag(filter.flag, song.flags)); diff --git a/src/app/services/filter.helper.spec.ts b/src/app/services/filter.helper.spec.ts index 6d90bf4..cab61d3 100644 --- a/src/app/services/filter.helper.spec.ts +++ b/src/app/services/filter.helper.spec.ts @@ -1,5 +1,5 @@ import {Song} from '../modules/songs/services/song'; -import {filterSong} from './filter.helper'; +import {createSongScorer, filterSong} from './filter.helper'; describe('Filter Helper', () => { const song: Song = { @@ -87,4 +87,12 @@ describe('Filter Helper', () => { it('should not find unrelated shortened wording', () => { 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); + }); }); diff --git a/src/app/services/filter.helper.ts b/src/app/services/filter.helper.ts index b9d3f31..2179b87 100644 --- a/src/app/services/filter.helper.ts +++ b/src/app/services/filter.helper.ts @@ -1,16 +1,49 @@ import {Song} from '../modules/songs/services/song'; 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 { - if (!filterValue) return () => true; + const scorer = createSongScorer(filterValue); + return (song: Song) => scorer(song) > 0; +} + +export function searchSongs(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); return (song: 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(); -function matchesAnalysis(haystack: SearchAnalysis | undefined, filter: SearchAnalysis): boolean { - if (!haystack) return false; +function scoreAnalysis(haystack: SearchAnalysis | undefined, filter: SearchAnalysis, fieldWeight: number): number { + 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 { @@ -41,15 +83,15 @@ function analyzeSearchText(input: string): SearchAnalysis { }; } -function matchesTokenSequence(haystackTokens: string[], needleTokens: string[]): boolean { - if (needleTokens.length === 0 || haystackTokens.length < needleTokens.length) return false; +function findTokenSequenceIndex(haystackTokens: string[], needleTokens: string[]): number { + if (needleTokens.length === 0 || haystackTokens.length < needleTokens.length) return -1; for (let start = 0; start <= haystackTokens.length - needleTokens.length; start++) { 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[] { diff --git a/src/app/widget-modules/components/add-song/add-song.component.ts b/src/app/widget-modules/components/add-song/add-song.component.ts index 6ab97e3..15bcfbe 100644 --- a/src/app/widget-modules/components/add-song/add-song.component.ts +++ b/src/app/widget-modules/components/add-song/add-song.component.ts @@ -1,7 +1,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, inject} from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; 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 {Song} from '../../../modules/songs/services/song'; import {ShowSong} from '../../../modules/shows/services/show-song'; @@ -56,10 +56,10 @@ export class AddSongComponent { return 1; } return 0; - }); + }); const filterValue = this.debouncedFilterValue; - return filterValue ? songs.filter(createSongFilter(filterValue)) : songs; + return filterValue ? searchSongs(songs, filterValue) : songs; } public async onAddSongSelectionChanged(event: MatSelectChange): Promise {