From 16776e2250fff4227968a04925d3cdc2964ab220 Mon Sep 17 00:00:00 2001 From: benjamin Date: Fri, 20 Mar 2026 20:44:59 +0100 Subject: [PATCH] tokenized text search --- .../song-list/filter/filter.component.ts | 5 +- .../songs/song-list/song-list.component.ts | 9 +- src/app/services/filter.helper.spec.ts | 24 ++++++ src/app/services/filter.helper.ts | 84 +++++++++++++++++-- .../components/add-song/add-song.component.ts | 22 ++++- .../navigation/filter/filter.component.ts | 13 ++- 6 files changed, 138 insertions(+), 19 deletions(-) diff --git a/src/app/modules/songs/song-list/filter/filter.component.ts b/src/app/modules/songs/song-list/filter/filter.component.ts index 351e200..a630e5b 100644 --- a/src/app/modules/songs/song-list/filter/filter.component.ts +++ b/src/app/modules/songs/song-list/filter/filter.component.ts @@ -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)); 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 594ea65..04b6576 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 {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); diff --git a/src/app/services/filter.helper.spec.ts b/src/app/services/filter.helper.spec.ts index 4aa490b..6d90bf4 100644 --- a/src/app/services/filter.helper.spec.ts +++ b/src/app/services/filter.helper.spec.ts @@ -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); + }); }); diff --git a/src/app/services/filter.helper.ts b/src/app/services/filter.helper.ts index d244aef..b9d3f31 100644 --- a/src/app/services/filter.helper.ts +++ b/src/app/services/filter.helper.ts @@ -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(); + +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 = (value: T, index: number, array: T[]) => array.indexOf(value) === index; 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 09a7a0c..6ab97e3 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,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('', {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 { diff --git a/src/app/widget-modules/components/application-frame/navigation/filter/filter.component.ts b/src/app/widget-modules/components/application-frame/navigation/filter/filter.component.ts index 69e9bce..c2feeff 100644 --- a/src/app/widget-modules/components/application-frame/navigation/filter/filter.component.ts +++ b/src/app/widget-modules/components/application-frame/navigation/filter/filter.component.ts @@ -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(); 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 { + public valueChange(text: string): void { + this.valueChanged$.next(text); + } + + private async applyValueChange(text: string): Promise { this.filterStore.updateSongFilter({q: text}); const route = this.router.createUrlTree(['songs'], { queryParams: {q: text},