tokenized text search
Some checks failed
Angular Build / build (push) Has been cancelled

This commit is contained in:
2026-03-20 20:44:59 +01:00
parent 7fe4339ce4
commit 16776e2250
6 changed files with 138 additions and 19 deletions

View File

@@ -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);
});
});

View File

@@ -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;