diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 483cb7400..0698eade5 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { useFindPerformersQuery } from "src/core/generated-graphql"; import { ObjectsFilter } from "./SelectableFilter"; +import { sortByRelevance } from "src/utils/query"; interface IPerformersFilter { criterion: PerformersCriterion; @@ -18,16 +19,18 @@ function usePerformerQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findPerformers.performers.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findPerformers.performers ?? [], + (p) => p.alias_list + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index cb10596ef..3960ec917 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { useFindStudiosQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { sortByRelevance } from "src/utils/query"; interface IStudiosFilter { criterion: StudiosCriterion; @@ -18,16 +19,18 @@ function useStudioQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findStudios.studios.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findStudios.studios ?? [], + (s) => s.aliases + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index 7b3479a54..c32a384bd 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { useFindTagsQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { sortByRelevance } from "src/utils/query"; interface ITagsFilter { criterion: StudiosCriterion; @@ -18,16 +19,18 @@ function useTagQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findTags.tags.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findTags.tags ?? [], + (t) => t.aliases + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index 98e04ddb8..fbf3e1225 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -26,6 +26,7 @@ import { } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; +import { sortByRelevance } from "src/utils/query"; export type SelectObject = { id: string; @@ -59,7 +60,11 @@ export const PerformerSelect: React.FC< filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; const query = await queryFindPerformersForSelect(filter); - return query.data.findPerformers.performers.map((performer) => ({ + return sortByRelevance( + input, + query.data.findPerformers.performers, + (p) => p.alias_list + ).map((performer) => ({ value: performer.id, object: performer, })); diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 74435b7a3..6b21d5a92 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -26,6 +26,7 @@ import { } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { sortByRelevance } from "src/utils/query"; export type SelectObject = { id: string; @@ -62,16 +63,16 @@ export const StudioSelect: React.FC< filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; const query = await queryFindStudiosForSelect(filter); - return query.data.findStudios.studios - .filter((studio) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(studio.id.toString()); - }) - .map((studio) => ({ - value: studio.id, - object: studio, - })); + let ret = query.data.findStudios.studios.filter((studio) => { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(studio.id.toString()); + }); + + return sortByRelevance(input, ret, (o) => o.aliases).map((studio) => ({ + value: studio.id, + object: studio, + })); } const StudioOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 7832ec9d9..80545be48 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -27,6 +27,7 @@ import { import { useCompare } from "src/hooks/state"; import { TagPopover } from "./TagPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { sortByRelevance } from "src/utils/query"; export type SelectObject = { id: string; @@ -63,16 +64,16 @@ export const TagSelect: React.FC< filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; const query = await queryFindTagsForSelect(filter); - return query.data.findTags.tags - .filter((tag) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(tag.id.toString()); - }) - .map((tag) => ({ - value: tag.id, - object: tag, - })); + let ret = query.data.findTags.tags.filter((tag) => { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(tag.id.toString()); + }); + + return sortByRelevance(input, ret, (o) => o.aliases).map((tag) => ({ + value: tag.id, + object: tag, + })); } const TagOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/utils/query.ts b/ui/v2.5/src/utils/query.ts new file mode 100644 index 000000000..fcf2af307 --- /dev/null +++ b/ui/v2.5/src/utils/query.ts @@ -0,0 +1,345 @@ +interface ISortable { + id: string; + name: string; +} + +// sortByRelevance is a function that sorts an array of objects by relevance to a query string. +// It uses the following priorities: +// 1. Exact matches +// 2. Starts with +// 3. Word matches +// 4. Word starts with +// 5. Includes +// If aliases are provided, they are also checked in the same order, but with lower priority than +// the name of the object. +export function sortByRelevance( + query: string, + value: T[], + getAliases?: (o: T) => string[] | undefined +) { + if (!query) { + return value; + } + + query = query.toLowerCase(); + + interface ICacheEntry { + aliases?: string[]; + aliasMatch?: boolean; + aliasStartsWith?: boolean; + wordIndex?: number; + wordStartsWithIndex?: number; + aliasWordIndex?: number; + aliasWordStartsWithIndex?: number; + aliasIncludesIndex?: number; + } + + const cache: Record = {}; + + function setCache(tag: T, partial: Partial) { + cache[tag.id] = { + ...cache[tag.id], + ...partial, + }; + } + + function getObjectAliases(o: T) { + const cached = cache[o.id]?.aliases; + + if (cached !== undefined) { + return cached; + } + + if (!getAliases) { + return []; + } + + const aliases = getAliases(o)?.map((a) => a.toLowerCase()) ?? []; + setCache(o, { aliases }); + + return aliases; + } + + function aliasMatches(o: T) { + const cached = cache[o.id]?.aliasMatch; + + if (cached !== undefined) { + return cached; + } + + const aliases = getObjectAliases(o); + const aliasMatch = aliases.some((a) => a === query); + setCache(o, { aliasMatch }); + + return aliasMatch; + } + + function aliasStartsWith(o: T) { + const cached = cache[o.id]?.aliasStartsWith; + + if (cached !== undefined) { + return cached; + } + + const aliases = getObjectAliases(o); + const startsWith = aliases.some((a) => a.startsWith(query)); + setCache(o, { aliasStartsWith: startsWith }); + + return startsWith; + } + + function getWords(o: T) { + return o.name.toLowerCase().split(" "); + } + + function getAliasWords(tag: T) { + const aliases = getObjectAliases(tag); + return aliases.map((a) => a.split(" ")).flat(); + } + + function getWordIndex(o: T) { + const cached = cache[o.id]?.wordIndex; + + if (cached !== undefined) { + return cached; + } + + const words = getWords(o); + const wordIndex = words.findIndex((w) => w === query); + setCache(o, { wordIndex }); + + return wordIndex; + } + + function getAliasWordIndex(o: T) { + const cached = cache[o.id]?.aliasWordIndex; + + if (cached !== undefined) { + return cached; + } + + const aliasWords = getAliasWords(o); + const aliasWordIndex = aliasWords.findIndex((w) => w === query); + setCache(o, { aliasWordIndex }); + + return aliasWordIndex; + } + + function getWordStartsWithIndex(o: T) { + const cached = cache[o.id]?.wordStartsWithIndex; + + if (cached !== undefined) { + return cached; + } + + const words = getWords(o); + const wordStartsWithIndex = words.findIndex((w) => w.startsWith(query)); + setCache(o, { wordStartsWithIndex }); + + return wordStartsWithIndex; + } + + function getAliasWordStartsWithIndex(o: T) { + const cached = cache[o.id]?.aliasWordStartsWithIndex; + + if (cached !== undefined) { + return cached; + } + + const aliasWords = getAliasWords(o); + const aliasWordStartsWithIndex = aliasWords.findIndex((w) => + w.startsWith(query) + ); + setCache(o, { aliasWordStartsWithIndex }); + + return aliasWordStartsWithIndex; + } + + function getAliasIncludesIndex(o: T) { + const cached = cache[o.id]?.aliasIncludesIndex; + + if (cached !== undefined) { + return cached; + } + + const aliases = getObjectAliases(o); + const aliasIncludesIndex = aliases.findIndex((a) => a.includes(query)); + setCache(o, { aliasIncludesIndex }); + + return aliasIncludesIndex; + } + + function compare(a: T, b: T) { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + + const aAlias = aliasMatches(a); + const bAlias = aliasMatches(b); + + // exact matches first + if (aName === query && bName !== query) { + return -1; + } + + if (aName !== query && bName === query) { + return 1; + } + + if (aAlias && !bAlias) { + return -1; + } + + if (!aAlias && bAlias) { + return 1; + } + + // then starts with + if (aName.startsWith(query) && !bName.startsWith(query)) { + return -1; + } + + if (!aName.startsWith(query) && bName.startsWith(query)) { + return 1; + } + + const aAliasStartsWith = aliasStartsWith(a); + const bAliasStartsWith = aliasStartsWith(b); + + if (aAliasStartsWith && !bAliasStartsWith) { + return -1; + } + + if (!aAliasStartsWith && bAliasStartsWith) { + return 1; + } + + // only check words if the query is a single word + if (!query.includes(" ")) { + // word matches + { + const aWord = getWordIndex(a); + const bWord = getWordIndex(b); + + if (aWord !== -1 && bWord === -1) { + return -1; + } + + if (aWord === -1 && bWord !== -1) { + return 1; + } + + if (aWord !== -1 && bWord !== -1) { + if (aWord === bWord) { + return aName.localeCompare(bName); + } + + return aWord - bWord; + } + + const aAliasWord = getAliasWordIndex(a); + const bAliasWord = getAliasWordIndex(b); + + if (aAliasWord !== -1 && bAliasWord === -1) { + return -1; + } + + if (aAliasWord === -1 && bAliasWord !== -1) { + return 1; + } + + if (aAliasWord !== -1 && bAliasWord !== -1) { + if (aAliasWord === bAliasWord) { + return aName.localeCompare(bName); + } + + return aAliasWord - bAliasWord; + } + } + + // then start of word + { + const aWord = getWordStartsWithIndex(a); + const bWord = getWordStartsWithIndex(b); + + if (aWord !== -1 && bWord === -1) { + return -1; + } + + if (aWord === -1 && bWord !== -1) { + return 1; + } + + if (aWord !== -1 && bWord !== -1) { + if (aWord === bWord) { + return aName.localeCompare(bName); + } + + return aWord - bWord; + } + + const aAliasWord = getAliasWordStartsWithIndex(a); + const bAliasWord = getAliasWordStartsWithIndex(b); + + if (aAliasWord !== -1 && bAliasWord === -1) { + return -1; + } + + if (aAliasWord === -1 && bAliasWord !== -1) { + return 1; + } + + if (aAliasWord !== -1 && bAliasWord !== -1) { + if (aAliasWord === bAliasWord) { + return aName.localeCompare(bName); + } + + return aAliasWord - bAliasWord; + } + } + } + + // then contains + // performance of this is presumably fast enough to not require caching + const aNameIncludeIndex = aName.indexOf(query); + const bNameIncludeIndex = bName.indexOf(query); + + if (aNameIncludeIndex !== -1 && bNameIncludeIndex === -1) { + return -1; + } + + if (aNameIncludeIndex === -1 && bNameIncludeIndex !== -1) { + return 1; + } + + if (aNameIncludeIndex !== -1 && bNameIncludeIndex !== -1) { + if (aNameIncludeIndex === bNameIncludeIndex) { + return aName.localeCompare(bName); + } + + return aNameIncludeIndex - bNameIncludeIndex; + } + + const aAliasIncludes = getAliasIncludesIndex(a); + const bAliasIncludes = getAliasIncludesIndex(b); + + if (aAliasIncludes !== -1 && bAliasIncludes === -1) { + return -1; + } + + if (aAliasIncludes === -1 && bAliasIncludes !== -1) { + return 1; + } + + if (aAliasIncludes !== -1 && bAliasIncludes !== -1) { + if (aAliasIncludes === bAliasIncludes) { + return aName.localeCompare(bName); + } + + return aAliasIncludes - bAliasIncludes; + } + + return aName.localeCompare(bName); + } + + return value.slice().sort(compare); +}