Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions newIDE/app/src/AssetStore/AssetStoreContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
type PrivateAssetPackListingData,
} from '../Utils/GDevelopServices/Shop';
import { useSearchItem, type SearchFilter } from '../UI/Search/UseSearchItem';
import {
getDefaultSearchItemRelevance,
SEARCH_BAND_WIDTH,
} from '../UI/Search/SearchItemRelevance';
import {
TagAssetStoreSearchFilter,
AnimatedAssetStoreSearchFilter,
Expand Down Expand Up @@ -149,6 +153,24 @@ const getAssetShortHeaderSearchTerms = (assetShortHeader: AssetShortHeader) => {
);
};

// Among the assets exactly matching the search, prefer 3D ones over 2D ones
// by lifting them one band higher.
const getAssetShortHeaderRelevance = (
assetShortHeader: AssetShortHeader,
itemText: string,
searchText: string
): number => {
const relevance = getDefaultSearchItemRelevance(
assetShortHeader,
itemText,
searchText
);
return assetShortHeader.objectType === 'Scene3D::Model3DObject' &&
relevance >= SEARCH_BAND_WIDTH
? relevance + SEARCH_BAND_WIDTH
: relevance;
};

const getPublicAssetPackSearchTerms = (assetPack: PublicAssetPack) =>
assetPack.name + '\n' + assetPack.tag;

Expand Down Expand Up @@ -477,7 +499,8 @@ export const AssetStoreStateProvider = ({
searchText,
chosenCategory,
chosenFilters,
assetSearchFilters
assetSearchFilters,
getAssetShortHeaderRelevance
);

// $FlowFixMe[incompatible-type] - this filter works for both public and private packs
Expand Down Expand Up @@ -601,7 +624,8 @@ export const AssetStoreStateProvider = ({
searchText,
chosenCategory,
chosenFilters,
searchFilters
searchFilters,
getAssetShortHeaderRelevance
),
setInitialPackUserFriendlySlug,
getAssetShortHeaderFromId,
Expand Down
113 changes: 113 additions & 0 deletions newIDE/app/src/UI/Search/SearchItemRelevance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @flow

/**
* Relevance scoring for searched items, ranking them in strict priority
* "bands": an item in a higher band always outranks any item in a lower band.
*/

// What the default relevance reads on an item. Extra properties are ignored.
// An interface, so class instances (like the resources store items) are also
// accepted.
interface SearchableItemProperties {
+name?: string;
+tags?: Array<string>;
}

// Contravariant in SearchItem: a scorer reading only the generic searchable
// properties can be used wherever a scorer for a more specific item is needed.
export type GetSearchItemRelevance<-SearchItem> = (
searchItem: SearchItem,
itemText: string,
searchText: string
) => number;

// Width of a priority band. Within-band scores stay well below this value, so
// callers can build extra bands by adding it to the default relevance.
export const SEARCH_BAND_WIDTH = 10;
// Weight of the name match, so a name match ranks above a tags-only match.
const NAME_MATCH_WEIGHT = 0.5;
// Floor of the many-tags malus, so a diluted match sinks but stays listed.
const MANY_TAGS_MALUS_FLOOR = 0.15;
// Minimum text relevance for a tag to count as matching.
const MATCHING_TAG_RELEVANCE_THRESHOLD = 0.5;

const escapeRegExp = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const getSearchWords = (searchText: string): Array<string> =>
searchText
.toLowerCase()
.split(/\s+/)
.filter(Boolean);

/**
* Text relevance of an item for the search words: whole-word match ("car" in
* "a car") > prefix match ("car" in "cartoon") > substring ("car" in "scar").
* Always strictly positive so a matched item is never excluded.
*/
const getTextSearchRelevance = (
itemText: string,
searchWords: Array<string>
): number => {
if (searchWords.length === 0) return 1;

const lowerCasedItemText = itemText.toLowerCase();
let totalScore = 0;
for (const searchWord of searchWords) {
const escapedWord = escapeRegExp(searchWord);
if (new RegExp(`\\b${escapedWord}\\b`).test(lowerCasedItemText)) {
totalScore += 1; // Whole-word match.
} else if (new RegExp(`\\b${escapedWord}`).test(lowerCasedItemText)) {
totalScore += 0.2; // Prefix match.
} else if (lowerCasedItemText.includes(searchWord)) {
totalScore += 0.1; // Match inside a word.
}
}

return Math.max(totalScore / searchWords.length, 0.05);
};

/**
* Default relevance, ranking items in two bands: "exact matches" (the whole
* search term is a complete word of the item name, or is exactly one of its
* tags) above everything else. Within a band, items are ordered by text
* relevance, refined by the name and diluted when few of many tags match.
*
* An item in the exact match band has a relevance >= SEARCH_BAND_WIDTH.
*/
export const getDefaultSearchItemRelevance: GetSearchItemRelevance<SearchableItemProperties> = (
searchItem,
itemText,
searchText
) => {
const searchWords = getSearchWords(searchText);
let withinBandScore = getTextSearchRelevance(itemText, searchWords);

const { name, tags } = searchItem;
const nameRelevance = name ? getTextSearchRelevance(name, searchWords) : 0;

// Dilute the match of an item matching through few of its many tags (e.g.
// an aerosol tagged "car" among 30 other tags).
if (tags && tags.length > 0) {
const matchingTagsCount = tags.filter(
tag =>
getTextSearchRelevance(tag, searchWords) >=
MATCHING_TAG_RELEVANCE_THRESHOLD
).length;
withinBandScore *=
MANY_TAGS_MALUS_FLOOR +
(1 - MANY_TAGS_MALUS_FLOOR) * (matchingTagsCount / tags.length);
}

withinBandScore += NAME_MATCH_WEIGHT * nameRelevance;

const normalizedSearchText = searchText.trim().toLowerCase();
const hasExactMatch =
nameRelevance >= 1 ||
!!(
tags &&
tags.some(tag => tag.trim().toLowerCase() === normalizedSearchText)
);

return withinBandScore + (hasExactMatch ? SEARCH_BAND_WIDTH : 0);
};
40 changes: 31 additions & 9 deletions newIDE/app/src/UI/Search/UseSearchItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
type PrivateGameTemplateListingData,
type BundleListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getDefaultSearchItemRelevance,
type GetSearchItemRelevance,
} from './SearchItemRelevance';

type SearchableItem =
| AssetShortHeader
Expand Down Expand Up @@ -126,7 +130,8 @@ export const filterSearchItems = <SearchItem: SearchableItem>(
searchItems: ?Array<SearchItem>,
chosenCategory: ?ChosenCategory,
chosenFilters: ?Set<string>,
searchFilters?: Array<SearchFilter<SearchItem>>
searchFilters?: Array<SearchFilter<SearchItem>>,
getSearchItemRelevance?: (searchItem: SearchItem) => number
): ?Array<SearchItem> => {
if (!searchItems) return null;

Expand Down Expand Up @@ -178,16 +183,22 @@ export const filterSearchItems = <SearchItem: SearchableItem>(
});

let sortedSearchItems = filteredSearchItems;
if (searchFilters) {
if (searchFilters || getSearchItemRelevance) {
let pertinenceMin = 1;
let pertinenceMax = 0;
const weightedSearchItems = filteredSearchItems
.map(searchItem => {
let pertinence = 1;
for (const searchFilter of searchFilters) {
pertinence *= searchFilter.getPertinence(searchItem);
if (pertinence === 0) {
return null;
// Seed the pertinence with the text search relevance so that whole-word
// matches are ranked above partial matches, then let the filters refine it.
let pertinence = getSearchItemRelevance
? getSearchItemRelevance(searchItem)
: 1;
if (searchFilters) {
for (const searchFilter of searchFilters) {
pertinence *= searchFilter.getPertinence(searchItem);
if (pertinence === 0) {
return null;
}
}
}
pertinenceMin = Math.min(pertinenceMin, pertinence);
Expand Down Expand Up @@ -229,7 +240,10 @@ export const useSearchItem = <SearchItem: SearchableItem>(
searchText: string,
chosenCategory: ?ChosenCategory,
chosenFilters: ?Set<string>,
searchFilters?: Array<SearchFilter<SearchItem>>
searchFilters?: Array<SearchFilter<SearchItem>>,
// Relevance of an item for the search: defined by the caller (which knows
// what is being searched), or this generic text-based default.
getSearchItemRelevance: GetSearchItemRelevance<SearchItem> = getDefaultSearchItemRelevance
): ?Array<SearchItem> => {
const searchApiRef = React.useRef<?any>(null);
const [searchResults, setSearchResults] = React.useState<?Array<SearchItem>>(
Expand Down Expand Up @@ -340,7 +354,13 @@ export const useSearchItem = <SearchItem: SearchableItem>(
partialSearchResults,
chosenCategory,
chosenFilters,
searchFilters
searchFilters,
searchItem =>
getSearchItemRelevance(
searchItem,
getItemDescription(searchItem),
searchText
)
)
);
});
Expand All @@ -363,6 +383,8 @@ export const useSearchItem = <SearchItem: SearchableItem>(
chosenFilters,
searchFilters,
searchApi,
getItemDescription,
getSearchItemRelevance,
]
);

Expand Down
Loading