diff --git a/newIDE/app/src/AssetStore/AssetStoreContext.js b/newIDE/app/src/AssetStore/AssetStoreContext.js index fd829cb31689..90edd09c80f5 100644 --- a/newIDE/app/src/AssetStore/AssetStoreContext.js +++ b/newIDE/app/src/AssetStore/AssetStoreContext.js @@ -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, @@ -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; @@ -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 @@ -601,7 +624,8 @@ export const AssetStoreStateProvider = ({ searchText, chosenCategory, chosenFilters, - searchFilters + searchFilters, + getAssetShortHeaderRelevance ), setInitialPackUserFriendlySlug, getAssetShortHeaderFromId, diff --git a/newIDE/app/src/UI/Search/SearchItemRelevance.js b/newIDE/app/src/UI/Search/SearchItemRelevance.js new file mode 100644 index 000000000000..a12afc480694 --- /dev/null +++ b/newIDE/app/src/UI/Search/SearchItemRelevance.js @@ -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; +} + +// 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 => + 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 +): 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 = ( + 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); +}; diff --git a/newIDE/app/src/UI/Search/UseSearchItem.js b/newIDE/app/src/UI/Search/UseSearchItem.js index de32939166ba..24f192f50e92 100644 --- a/newIDE/app/src/UI/Search/UseSearchItem.js +++ b/newIDE/app/src/UI/Search/UseSearchItem.js @@ -14,6 +14,10 @@ import { type PrivateGameTemplateListingData, type BundleListingData, } from '../../Utils/GDevelopServices/Shop'; +import { + getDefaultSearchItemRelevance, + type GetSearchItemRelevance, +} from './SearchItemRelevance'; type SearchableItem = | AssetShortHeader @@ -126,7 +130,8 @@ export const filterSearchItems = ( searchItems: ?Array, chosenCategory: ?ChosenCategory, chosenFilters: ?Set, - searchFilters?: Array> + searchFilters?: Array>, + getSearchItemRelevance?: (searchItem: SearchItem) => number ): ?Array => { if (!searchItems) return null; @@ -178,16 +183,22 @@ export const filterSearchItems = ( }); 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); @@ -229,7 +240,10 @@ export const useSearchItem = ( searchText: string, chosenCategory: ?ChosenCategory, chosenFilters: ?Set, - searchFilters?: Array> + searchFilters?: Array>, + // 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 = getDefaultSearchItemRelevance ): ?Array => { const searchApiRef = React.useRef(null); const [searchResults, setSearchResults] = React.useState>( @@ -340,7 +354,13 @@ export const useSearchItem = ( partialSearchResults, chosenCategory, chosenFilters, - searchFilters + searchFilters, + searchItem => + getSearchItemRelevance( + searchItem, + getItemDescription(searchItem), + searchText + ) ) ); }); @@ -363,6 +383,8 @@ export const useSearchItem = ( chosenFilters, searchFilters, searchApi, + getItemDescription, + getSearchItemRelevance, ] );