Skip to content

Commit

Permalink
ignore special characters in search, fixes #264
Browse files Browse the repository at this point in the history
  • Loading branch information
Asvarox committed Jun 24, 2024
1 parent 8937c4a commit 28d1f8c
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 137 deletions.
7 changes: 6 additions & 1 deletion src/modules/utils/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FrequencyRecord, Note, NoteFrequencyRecord, PlayerNote, Section, Song } from 'interfaces';
import { FrequencyRecord, Note, NoteFrequencyRecord, PlayerNote, Section, Song, SongPreview } from 'interfaces';
import { getSongPreview } from 'modules/Songs/utils';
import range from 'modules/utils/range';

export const generateNote = (start: number, length = 1, data: Partial<Note> = {}): Note => ({
Expand Down Expand Up @@ -33,6 +34,7 @@ export const generateSong = (tracks: Section[][], data: Partial<Song> = {}): Son
artist: 'artistTest',
title: 'titleTest',
video: 'videoTest',
language: ['english'],
gap: 0,
bpm: 60, // makes it easy to calc - beatLength = 1ms
bar: 1000, // makes it easy to calc - beatLength = 1ms
Expand All @@ -41,6 +43,9 @@ export const generateSong = (tracks: Section[][], data: Partial<Song> = {}): Son
...data,
}) as any as Song;

export const generateSongPreview = (tracks: Section[][], data: Partial<Song> = {}): SongPreview =>
getSongPreview(generateSong(tracks, data));

export const generateSection = (start: number, length: number, notesCount: number): Section => ({
type: 'notes',
start,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Tooltip } from 'modules/Elements/Tooltip';
import styles from 'modules/GameEngine/Drawing/styles';
import { Dispatch, SetStateAction, useState } from 'react';
import QuickSearch from 'routes/SingASong/SongSelection/Components/QuickSearch';
import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongList';

import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

interface Props {
onRandom: () => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useEventEffect } from 'modules/GameEvents/hooks';
import { REGULAR_ALPHA_CHARS } from 'modules/hooks/useKeyboard';
import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongList';

import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

interface Props {
setFilters: Dispatch<SetStateAction<AppliedFilters>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useSongStats } from 'modules/Songs/stats/hooks';
import { isEurovisionSong } from 'modules/Songs/utils/specialSongsThemeChecks';
import { ReactNode, useMemo } from 'react';
import eurovisionIcon from 'routes/SingASong/SongSelection/Components/SongCard/eurovision-icon.svg';
import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongList';

import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

export const TopContainer = (props: { song: SongPreview; isPopular: boolean; video?: ReactNode }) => {
return (
Expand Down
3 changes: 2 additions & 1 deletion src/routes/SingASong/SongSelection/Hooks/usePlaylists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { ClosableTooltip } from 'modules/Elements/Tooltip';
// import eurovisionIcon from 'routes/SingASong/SongSelection/Components/SongCard/eurovision-icon.svg';
import { ReactElement, ReactNode, useMemo } from 'react';
import { useLanguageList } from 'routes/ExcludeLanguages/ExcludeLanguagesView';
import { AppliedFilters, SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList';
import { SongGroup } from 'routes/SingASong/SongSelection/Hooks/useSongList';
import { AppliedFilters } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

export interface PlaylistEntry {
name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { getAllStats } from 'modules/Songs/stats/common';
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState';
import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongList';

import { filteringFunctions } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

const POPULAR_SONGS_MIN_COUNT = 250;
const POPULAR_SONGS_MAX_COUNT = 750;
Expand Down
132 changes: 2 additions & 130 deletions src/routes/SingASong/SongSelection/Hooks/useSongList.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { captureException } from '@sentry/react';
import dayjs from 'dayjs';
import { SongPreview } from 'interfaces';
import useSongIndex from 'modules/Songs/hooks/useSongIndex';
import clearString from 'modules/utils/clearString';
import { ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState';
import { usePlaylists } from 'routes/SingASong/SongSelection/Hooks/usePlaylists';
import { ReactNode, useMemo } from 'react';
import useRecommendedSongs from 'routes/SingASong/SongSelection/Hooks/useRecommendedSongs';
import { useSongListFilter } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';

export interface SongGroup {
name: string;
Expand All @@ -17,24 +13,6 @@ export interface SongGroup {
isNew?: boolean;
}

export interface AppliedFilters {
yearBefore?: number;
yearAfter?: number;
language?: string;
excludeLanguages?: string[];
search?: string;
edition?: string;
recentlyUpdated?: boolean | null;
duet?: boolean | null;
specificSongs?: string[];
}

type FilterFunc = (songList: SongPreview[], ...args: any) => SongPreview[];

const isSearchApplied = (appliedFilters: AppliedFilters) => clearString(appliedFilters?.search ?? '').length > 2;

const emptyFilters: AppliedFilters = {};

const groupSongsByLetter = (song: SongPreview): Pick<SongGroup, 'name'> => {
const nonAlphaRegex = /[^a-zA-Z]/;

Expand All @@ -43,112 +21,6 @@ const groupSongsByLetter = (song: SongPreview): Pick<SongGroup, 'name'> => {
};
};

export const filteringFunctions: Record<keyof AppliedFilters, FilterFunc> = {
language: (songList, language: string) => {
if (language === '') return songList;

return songList.filter((song) => {
return song.language.includes(language);
});
},
excludeLanguages: (songList, languages: string[] = [], appliedFilters: AppliedFilters) => {
if (languages.length === 0 || isSearchApplied(appliedFilters) || appliedFilters.edition === 'esc') return songList;

return songList.filter((song) => {
return !song.language.every((songLang) => languages.includes(songLang!));
});
},
search: (songList, search: string) => {
const cleanSearch = clearString(search);

if (cleanSearch.length > 0) {
const fuzz = new uFuzzy({});
const [, info, order] = fuzz.search(
songList.map((song) => `${clearString(song.artist)} ${clearString(song.title)}`),
search,
);
if (order && info) {
return order.map((item) => songList[info.idx[item]]);
}
}

return cleanSearch.length > 0 ? songList.filter((song) => song.search.includes(cleanSearch)) : songList;
},
duet: (songList, duet: boolean | null) => {
if (duet === null) return songList;

return songList.filter((song) => (duet ? song.tracksCount > 1 : song.tracksCount === 1));
},
yearBefore: (songList, yearBefore: number) => {
if (!yearBefore) return songList;

return songList.filter((song) => Number(song.year) < yearBefore);
},
yearAfter: (songList, yearAfter: number) => {
if (!yearAfter) return songList;

return songList.filter((song) => Number(song.year) >= yearAfter);
},
edition: (songList, edition: string) => {
const cleanEdition = clearString(edition);

return cleanEdition.length
? songList.filter((song) => clearString(song.edition ?? '').includes(edition))
: songList;
},
recentlyUpdated: (songList) => {
const after = dayjs().subtract(31, 'days');

return songList.filter((song) => song.lastUpdate && dayjs(song.lastUpdate).isAfter(after));
},
specificSongs: (songList, specificSongs: string[], appliedFilters: AppliedFilters) => {
if (isSearchApplied(appliedFilters)) return songList;

return songList.filter((song) => specificSongs.includes(song.id));
},
};

const applyFilters = (list: SongPreview[], appliedFilters: AppliedFilters): SongPreview[] => {
return Object.entries(appliedFilters)
.filter((filters): filters is [keyof AppliedFilters, FilterFunc] => filters[0] in filteringFunctions)
.reduce((songList, [name, value]) => filteringFunctions[name](songList, value, appliedFilters, list), list);
};

export const useSongListFilter = (list: SongPreview[], popular: string[], isLoading: boolean) => {
const [excludedLanguages] = useSettingValue(ExcludedLanguagesSetting);
const prefilteredList = useMemo(
() => applyFilters(list, { excludeLanguages: excludedLanguages ?? [] }),
[list, excludedLanguages],
);

const playlists = usePlaylists(prefilteredList, popular, isLoading);

const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(
new URLSearchParams(global.location?.search).get('playlist') ?? null,
);
const [filters, setFilters] = useState<AppliedFilters>(emptyFilters);

useEffect(() => {
setFilters(emptyFilters);
}, [selectedPlaylist]);

const deferredFilters = useDeferredValue(filters);

const playlist = playlists.find((p) => p.name === selectedPlaylist) ?? playlists[0];

const filteredList = useMemo(
() =>
applyFilters(list, {
...(playlist?.filters ?? {}),
...deferredFilters,
excludeLanguages: excludedLanguages ?? [],
}),
[list, deferredFilters, excludedLanguages, playlist],
);

return { filters, filteredList, setFilters, selectedPlaylist, setSelectedPlaylist, playlists, playlist };
};

export default function useSongList() {
const songList = useSongIndex();
const {
Expand Down
35 changes: 35 additions & 0 deletions src/routes/SingASong/SongSelection/Hooks/useSongListFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { act, renderHook } from '@testing-library/react';
import { SongPreview } from 'interfaces';
import { generateSongPreview } from 'modules/utils/testUtils';
import { useSongListFilter } from 'routes/SingASong/SongSelection/Hooks/useSongListFilter';
import { beforeEach } from 'vitest';

const list: SongPreview[] = [
generateSongPreview([], { artist: 'diacritics characters', title: 'konik na biegunach' }),
generateSongPreview([], { artist: 'kombi', title: 'pokolenie' }),
];

describe('useSongListFilter', () => {
beforeEach(() => {
global.location.search = '?playlist=All';
});
it('should return filtered list', () => {
const { result } = renderHook(() => useSongListFilter(list, [], false));

act(() => {
result.current.setFilters({ search: 'koń' });
});

expect(result.current.filteredList[0]).toEqual(list[0]);
});

it('should return all songs when search is empty', () => {
const { result } = renderHook(() => useSongListFilter(list, [], false));

act(() => {
result.current.setFilters({ search: ' ' });
});

expect(result.current.filteredList).toEqual(list);
});
});
131 changes: 131 additions & 0 deletions src/routes/SingASong/SongSelection/Hooks/useSongListFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import uFuzzy from '@leeoniya/ufuzzy';
import dayjs from 'dayjs';
import { SongPreview } from 'interfaces';
import clearString, { removeAccents } from 'modules/utils/clearString';
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { ExcludedLanguagesSetting, useSettingValue } from 'routes/Settings/SettingsState';
import { usePlaylists } from 'routes/SingASong/SongSelection/Hooks/usePlaylists';

type FilterFunc = (songList: SongPreview[], ...args: any) => SongPreview[];

export interface AppliedFilters {
yearBefore?: number;
yearAfter?: number;
language?: string;
excludeLanguages?: string[];
search?: string;
edition?: string;
recentlyUpdated?: boolean | null;
duet?: boolean | null;
specificSongs?: string[];
}

const isSearchApplied = (appliedFilters: AppliedFilters) => clearString(appliedFilters?.search ?? '').length > 2;

const emptyFilters: AppliedFilters = {};

export const filteringFunctions: Record<keyof AppliedFilters, FilterFunc> = {
language: (songList, language: string) => {
if (language === '') return songList;

return songList.filter((song) => {
return song.language.includes(language);
});
},
excludeLanguages: (songList, languages: string[] = [], appliedFilters: AppliedFilters) => {
if (languages.length === 0 || isSearchApplied(appliedFilters) || appliedFilters.edition === 'esc') return songList;

return songList.filter((song) => {
return !song.language.every((songLang) => languages.includes(songLang!));
});
},
search: (songList, search: string) => {
const cleanSearch = clearString(search);

if (cleanSearch.length > 0) {
const fuzz = new uFuzzy({});
const [, info, order] = fuzz.search(
songList.map((song) => `${removeAccents(song.artist)} ${removeAccents(song.title)}`),
removeAccents(search),
);
if (order && info) {
return order.map((item) => songList[info.idx[item]]);
} else {
return songList.filter((song) => song.search.includes(cleanSearch));
}
}

return songList;
},
duet: (songList, duet: boolean | null) => {
if (duet === null) return songList;

return songList.filter((song) => (duet ? song.tracksCount > 1 : song.tracksCount === 1));
},
yearBefore: (songList, yearBefore: number) => {
if (!yearBefore) return songList;

return songList.filter((song) => Number(song.year) < yearBefore);
},
yearAfter: (songList, yearAfter: number) => {
if (!yearAfter) return songList;

return songList.filter((song) => Number(song.year) >= yearAfter);
},
edition: (songList, edition: string) => {
const cleanEdition = clearString(edition);

return cleanEdition.length
? songList.filter((song) => clearString(song.edition ?? '').includes(edition))
: songList;
},
recentlyUpdated: (songList) => {
const after = dayjs().subtract(31, 'days');

return songList.filter((song) => song.lastUpdate && dayjs(song.lastUpdate).isAfter(after));
},
specificSongs: (songList, specificSongs: string[], appliedFilters: AppliedFilters) => {
if (isSearchApplied(appliedFilters)) return songList;

return songList.filter((song) => specificSongs.includes(song.id));
},
};
const applyFilters = (list: SongPreview[], appliedFilters: AppliedFilters): SongPreview[] => {
return Object.entries(appliedFilters)
.filter((filters): filters is [keyof AppliedFilters, FilterFunc] => filters[0] in filteringFunctions)
.reduce((songList, [name, value]) => filteringFunctions[name](songList, value, appliedFilters, list), list);
};

export const useSongListFilter = (list: SongPreview[], popular: string[], isLoading: boolean) => {
const [excludedLanguages] = useSettingValue(ExcludedLanguagesSetting);
const prefilteredList = useMemo(
() => applyFilters(list, { excludeLanguages: excludedLanguages ?? [] }),
[list, excludedLanguages],
);

const playlists = usePlaylists(prefilteredList, popular, isLoading);

const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(
new URLSearchParams(global.location?.search).get('playlist') ?? null,
);
const playlist = playlists.find((p) => p.name === selectedPlaylist) ?? playlists[0];
const [filters, setFilters] = useState<AppliedFilters>(emptyFilters);

useEffect(() => {
setFilters(emptyFilters);
}, [selectedPlaylist]);

const deferredFilters = useDeferredValue(filters);

const filteredList = useMemo(
() =>
applyFilters(list, {
...(playlist?.filters ?? {}),
...deferredFilters,
excludeLanguages: excludedLanguages ?? [],
}),
[list, deferredFilters, excludedLanguages, playlist],
);

return { filters, filteredList, setFilters, selectedPlaylist, setSelectedPlaylist, playlists, playlist };
};
Loading

0 comments on commit 28d1f8c

Please sign in to comment.