From 61fc834b666f8bfe1c360414aff0fe6190776191 Mon Sep 17 00:00:00 2001 From: Konstantin Baltsat Date: Tue, 28 Jul 2026 13:06:21 +0000 Subject: [PATCH] feat: normalize local song search User-Request: split SightKick #42 into independently reviewable features | codex:oss-maintainer-campaign Signed-off-by: Konstantin Baltsat --- e2e/smoke.e2e.ts | 17 ++++ e2e/support.ts | 1 + src/renderer/hooks/useSongFilter.ts | 8 +- src/renderer/songSearch.test.ts | 39 ++++++++++ src/renderer/songSearch.ts | 77 +++++++++++++++++++ .../views/SongListView/SongListView.test.tsx | 21 +++++ 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/renderer/songSearch.test.ts create mode 100644 src/renderer/songSearch.ts diff --git a/e2e/smoke.e2e.ts b/e2e/smoke.e2e.ts index 74856d13..995e4bfb 100644 --- a/e2e/smoke.e2e.ts +++ b/e2e/smoke.e2e.ts @@ -29,6 +29,23 @@ test.describe('first run', () => { }); test.describe('seeded library', () => { + test('finds a song by normalized album and charter metadata', async () => { + harness = await launchApp({ seedLibrary: true }); + page = await harness.app.firstWindow(); + + await page.getByTestId('settings-trigger').click(); + await page.getByTestId('rescan-folder').click(); + + const song = page.getByText('Master of Puppets').first(); + + await expect(song).toBeVisible({ timeout: 30_000 }); + + for (const query of ['metal masters', 'test charter']) { + await page.getByTestId('song-search').fill(query); + await expect(song).toBeVisible(); + } + }); + test('scans the folder, lists the song, and renders real sheet music', async () => { harness = await launchApp({ seedLibrary: true }); page = await harness.app.firstWindow(); diff --git a/e2e/support.ts b/e2e/support.ts index 41aa044c..569fbbe0 100644 --- a/e2e/support.ts +++ b/e2e/support.ts @@ -29,6 +29,7 @@ function writeFixtureLibrary(): string { '[song]', 'name = Master of Puppets', 'artist = Metallica', + 'album = Métal Masters', 'charter = Test Charter', 'pro_drums = True', 'five_lane_drums = False', diff --git a/src/renderer/hooks/useSongFilter.ts b/src/renderer/hooks/useSongFilter.ts index 6c4e9b79..0a56683e 100644 --- a/src/renderer/hooks/useSongFilter.ts +++ b/src/renderer/hooks/useSongFilter.ts @@ -1,11 +1,11 @@ import { useMemo, useState } from 'react'; -import Fuse from 'fuse.js'; import { Difficulty } from 'scan-chart'; import { Song } from '../../types'; import { type SortState } from '../components/SortButton'; import { useOnlineSearch } from './useOnlineSearch'; import { usePersisted } from './usePersisted'; import { LibraryMode } from '../types'; +import { searchLocalSongs } from '../songSearch'; export function useSongFilter(songList: Song[], difficulty: Difficulty) { const [nameFilter, setNameFilter] = useState(''); @@ -30,11 +30,7 @@ export function useSongFilter(songList: Song[], difficulty: Difficulty) { ); if (nameFilter) { - const fuse = new Fuse(byDifficulty, { - keys: ['name', 'artist', 'charter'], - }); - - return fuse.search(nameFilter).map((result) => result.item); + return searchLocalSongs(byDifficulty, nameFilter); } return [...byDifficulty].sort((a, b) => { diff --git a/src/renderer/songSearch.test.ts b/src/renderer/songSearch.test.ts new file mode 100644 index 00000000..f778f2b5 --- /dev/null +++ b/src/renderer/songSearch.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { makeListSong } from './views/test-support'; +import { normalizeSearchText, searchLocalSongs } from './songSearch'; + +describe('normalizeSearchText', () => { + it('folds case, diacritics and whitespace', () => { + expect(normalizeSearchText(' BEYONCÉ Knowles ')).toBe( + 'beyonce knowles', + ); + }); +}); + +describe('searchLocalSongs', () => { + const songs = [ + makeListSong('raging', { + name: 'Raging', + artist: 'Kygo feat. Kodaline', + album: 'Cloud Nine', + charter: '', + }), + makeListSong('lose', { + name: 'Lose Somebody', + artist: 'Kygo & OneRepublic', + album: 'Golden Hour', + charter: 'Human Charter', + }), + ]; + + it.each([ + ['Kodaline', 'raging'], + ['Cloud Nine', 'raging'], + ['Human Charter', 'lose'], + ['onerepublic', 'lose'], + ])('matches %s across local metadata', (query, expectedId) => { + expect(searchLocalSongs(songs, query).map((song) => song.id)).toEqual([ + expectedId, + ]); + }); +}); diff --git a/src/renderer/songSearch.ts b/src/renderer/songSearch.ts new file mode 100644 index 00000000..2dc2b3f5 --- /dev/null +++ b/src/renderer/songSearch.ts @@ -0,0 +1,77 @@ +import Fuse from 'fuse.js'; +import { Song } from '../types'; + +type SearchableSong = Pick & + Partial>; + +const DIACRITICS = /\p{Diacritic}/gu; +const WHITESPACE = /\s+/g; + +export function normalizeSearchText(value = ''): string { + return value + .normalize('NFKD') + .replace(DIACRITICS, '') + .toLocaleLowerCase() + .replace(WHITESPACE, ' ') + .trim(); +} + +function fields(song: SearchableSong): string[] { + return [song.name, song.artist, song.album ?? '', song.charter] + .map(normalizeSearchText) + .filter(Boolean); +} + +function matchRank(song: SearchableSong, query: string): number { + const values = fields(song); + + if (values.some((value) => value === query)) { + return 0; + } + + if (values.some((value) => value.startsWith(query))) { + return 1; + } + + if (values.some((value) => value.includes(query))) { + return 2; + } + + return 3; +} + +export function searchLocalSongs(songs: Song[], input: string): Song[] { + const query = normalizeSearchText(input); + + if (!query) { + return [...songs]; + } + + const indexed = songs.map((song, index) => ({ + song, + index, + searchText: fields(song).join(' '), + })); + const fuse = new Fuse(indexed, { + keys: ['searchText'], + includeScore: true, + ignoreLocation: true, + threshold: 0.35, + }); + + return fuse + .search(query) + .sort((a, b) => { + const rank = + matchRank(a.item.song, query) - matchRank(b.item.song, query); + + if (rank !== 0) { + return rank; + } + + const score = (a.score ?? 1) - (b.score ?? 1); + + return score !== 0 ? score : a.item.index - b.item.index; + }) + .map((result) => result.item.song); +} diff --git a/src/renderer/views/SongListView/SongListView.test.tsx b/src/renderer/views/SongListView/SongListView.test.tsx index 6f611394..4b1a371b 100644 --- a/src/renderer/views/SongListView/SongListView.test.tsx +++ b/src/renderer/views/SongListView/SongListView.test.tsx @@ -115,6 +115,27 @@ describe('SongListView — filtering and sorting', () => { expect(screen.queryByText('Two')).not.toBeInTheDocument(); }); + it('searches local album, charter and folded diacritics', () => { + const view = setupSongListView(); + + view.loadSongs([ + makeListSong('raging', { + name: 'Raging', + artist: 'Kygo feat. Kodaliné', + album: 'Cloud Nine', + charter: 'Community Charter', + }), + makeListSong('other', { name: 'Other' }), + ]); + + for (const query of ['kodaline', 'cloud nine', 'community charter']) { + view.search(query); + + expect(screen.getByText('Raging')).toBeInTheDocument(); + expect(screen.queryByText('Other')).not.toBeInTheDocument(); + } + }); + it('reorders the list when a sort option is chosen', () => { const view = setupSongListView();