mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +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 { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||||
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||||
import { ObjectsFilter } from "./SelectableFilter";
|
import { ObjectsFilter } from "./SelectableFilter";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
interface IPerformersFilter {
|
interface IPerformersFilter {
|
||||||
criterion: PerformersCriterion;
|
criterion: PerformersCriterion;
|
||||||
@@ -18,16 +19,18 @@ function usePerformerQuery(query: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(
|
const results = useMemo(() => {
|
||||||
() =>
|
return sortByRelevance(
|
||||||
data?.findPerformers.performers.map((p) => {
|
query,
|
||||||
|
data?.findPerformers.performers ?? [],
|
||||||
|
(p) => p.alias_list
|
||||||
|
).map((p) => {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
label: p.name,
|
label: p.name,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
});
|
||||||
[data]
|
}, [data, query]);
|
||||||
);
|
|
||||||
|
|
||||||
return { results, loading };
|
return { results, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
|
|||||||
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
||||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
interface IStudiosFilter {
|
interface IStudiosFilter {
|
||||||
criterion: StudiosCriterion;
|
criterion: StudiosCriterion;
|
||||||
@@ -18,16 +19,18 @@ function useStudioQuery(query: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(
|
const results = useMemo(() => {
|
||||||
() =>
|
return sortByRelevance(
|
||||||
data?.findStudios.studios.map((p) => {
|
query,
|
||||||
|
data?.findStudios.studios ?? [],
|
||||||
|
(s) => s.aliases
|
||||||
|
).map((p) => {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
label: p.name,
|
label: p.name,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
});
|
||||||
[data]
|
}, [data, query]);
|
||||||
);
|
|
||||||
|
|
||||||
return { results, loading };
|
return { results, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
|
|||||||
import { useFindTagsQuery } from "src/core/generated-graphql";
|
import { useFindTagsQuery } from "src/core/generated-graphql";
|
||||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
interface ITagsFilter {
|
interface ITagsFilter {
|
||||||
criterion: StudiosCriterion;
|
criterion: StudiosCriterion;
|
||||||
@@ -18,16 +19,18 @@ function useTagQuery(query: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = useMemo(
|
const results = useMemo(() => {
|
||||||
() =>
|
return sortByRelevance(
|
||||||
data?.findTags.tags.map((p) => {
|
query,
|
||||||
|
data?.findTags.tags ?? [],
|
||||||
|
(t) => t.aliases
|
||||||
|
).map((p) => {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
label: p.name,
|
label: p.name,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
});
|
||||||
[data]
|
}, [data, query]);
|
||||||
);
|
|
||||||
|
|
||||||
return { results, loading };
|
return { results, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "../Shared/FilterSelect";
|
} from "../Shared/FilterSelect";
|
||||||
import { useCompare } from "src/hooks/state";
|
import { useCompare } from "src/hooks/state";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,7 +60,11 @@ export const PerformerSelect: React.FC<
|
|||||||
filter.sortBy = "name";
|
filter.sortBy = "name";
|
||||||
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
||||||
const query = await queryFindPerformersForSelect(filter);
|
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,
|
value: performer.id,
|
||||||
object: performer,
|
object: performer,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "../Shared/FilterSelect";
|
} from "../Shared/FilterSelect";
|
||||||
import { useCompare } from "src/hooks/state";
|
import { useCompare } from "src/hooks/state";
|
||||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,13 +63,13 @@ export const StudioSelect: React.FC<
|
|||||||
filter.sortBy = "name";
|
filter.sortBy = "name";
|
||||||
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
||||||
const query = await queryFindStudiosForSelect(filter);
|
const query = await queryFindStudiosForSelect(filter);
|
||||||
return query.data.findStudios.studios
|
let ret = query.data.findStudios.studios.filter((studio) => {
|
||||||
.filter((studio) => {
|
|
||||||
// HACK - we should probably exclude these in the backend query, but
|
// HACK - we should probably exclude these in the backend query, but
|
||||||
// this will do in the short-term
|
// this will do in the short-term
|
||||||
return !exclude.includes(studio.id.toString());
|
return !exclude.includes(studio.id.toString());
|
||||||
})
|
});
|
||||||
.map((studio) => ({
|
|
||||||
|
return sortByRelevance(input, ret, (o) => o.aliases).map((studio) => ({
|
||||||
value: studio.id,
|
value: studio.id,
|
||||||
object: studio,
|
object: studio,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { useCompare } from "src/hooks/state";
|
import { useCompare } from "src/hooks/state";
|
||||||
import { TagPopover } from "./TagPopover";
|
import { TagPopover } from "./TagPopover";
|
||||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||||
|
import { sortByRelevance } from "src/utils/query";
|
||||||
|
|
||||||
export type SelectObject = {
|
export type SelectObject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,13 +64,13 @@ export const TagSelect: React.FC<
|
|||||||
filter.sortBy = "name";
|
filter.sortBy = "name";
|
||||||
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
filter.sortDirection = GQL.SortDirectionEnum.Asc;
|
||||||
const query = await queryFindTagsForSelect(filter);
|
const query = await queryFindTagsForSelect(filter);
|
||||||
return query.data.findTags.tags
|
let ret = query.data.findTags.tags.filter((tag) => {
|
||||||
.filter((tag) => {
|
|
||||||
// HACK - we should probably exclude these in the backend query, but
|
// HACK - we should probably exclude these in the backend query, but
|
||||||
// this will do in the short-term
|
// this will do in the short-term
|
||||||
return !exclude.includes(tag.id.toString());
|
return !exclude.includes(tag.id.toString());
|
||||||
})
|
});
|
||||||
.map((tag) => ({
|
|
||||||
|
return sortByRelevance(input, ret, (o) => o.aliases).map((tag) => ({
|
||||||
value: tag.id,
|
value: tag.id,
|
||||||
object: tag,
|
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