mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Improve sorting of results when entering text in select fields (#4528)
* Sort select results by relevance * Apply relevance sorting to studio select * Apply relevance sorting to filter select
This commit is contained in:
@@ -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) => {
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findPerformers.performers ?? [],
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
}) ?? [],
|
||||
[data]
|
||||
);
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findStudios.studios ?? [],
|
||||
(s) => s.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
}) ?? [],
|
||||
[data]
|
||||
);
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findTags.tags ?? [],
|
||||
(t) => t.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
}) ?? [],
|
||||
[data]
|
||||
);
|
||||
});
|
||||
}, [data, query]);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,13 +63,13 @@ 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) => {
|
||||
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());
|
||||
})
|
||||
.map((studio) => ({
|
||||
});
|
||||
|
||||
return sortByRelevance(input, ret, (o) => o.aliases).map((studio) => ({
|
||||
value: studio.id,
|
||||
object: studio,
|
||||
}));
|
||||
|
||||
@@ -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,13 +64,13 @@ 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) => {
|
||||
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());
|
||||
})
|
||||
.map((tag) => ({
|
||||
});
|
||||
|
||||
return sortByRelevance(input, ret, (o) => o.aliases).map((tag) => ({
|
||||
value: tag.id,
|
||||
object: tag,
|
||||
}));
|
||||
|
||||
345
ui/v2.5/src/utils/query.ts
Normal file
345
ui/v2.5/src/utils/query.ts
Normal file
@@ -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<T extends ISortable>(
|
||||
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<string, ICacheEntry> = {};
|
||||
|
||||
function setCache(tag: T, partial: Partial<ICacheEntry>) {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user