diff --git a/src/app/search/autocomplete.test.ts b/src/app/search/autocomplete.test.ts index 3c2543daea..31b713a143 100644 --- a/src/app/search/autocomplete.test.ts +++ b/src/app/search/autocomplete.test.ts @@ -33,6 +33,7 @@ describe('autocompleteTermSuggestions', () => { ['not(', 'Expected failure'], ['memento:', 'memento:any'], ['foo memento:', 'foo memento:any'], + ['dupe:stats shot', 'dupe:stats is:shotgun'], ]; const plainStringCases: [query: string, mockCandidate: string][] = [['jotu', 'jötunn']]; diff --git a/src/app/search/autocomplete.ts b/src/app/search/autocomplete.ts index ce456a604a..9c402b8a6d 100644 --- a/src/app/search/autocomplete.ts +++ b/src/app/search/autocomplete.ts @@ -1,6 +1,7 @@ import { Search } from '@destinyitemmanager/dim-api-types'; import { compact, filterMap, uniqBy } from 'app/utils/collections'; import { chainComparator, compareBy, reverseComparator } from 'app/utils/comparators'; +import { partition } from 'es-toolkit'; import { ArmoryEntry, getArmorySuggestions } from './armory-search'; import { filterDescriptionText } from './filter-description'; import { canonicalFilterFormats } from './filter-types'; @@ -253,11 +254,15 @@ const caretEndRegex = /[\s)]|$/; * * @returns the start indexes of various points that could be incomplete filters */ -function findLastFilter(queryUpToCaret: string): number[] | null { +function findLastFilter( + queryUpToCaret: string, + searchConfig: SearchConfig, +): number[] | null { // Find the indexes where any incomplete filter starts. For example if the query is: // name:"foo" bar baz // then the open keywords are "bar baz" and "baz" let incompleteFilterIndices: number[] = []; + let lastWasFreeform = false; try { // We can use the query lexer for this to scan through tokens in the query without parsing the whole AST. for (const token of lexer(queryUpToCaret)) { @@ -268,7 +273,14 @@ function findLastFilter(queryUpToCaret: string): number[] | null { // Ignore complete quoted tokens, they're definitively finished. !token.quoted ) { + // If the last filter wasn't freeform, close it off and start a new one. + if (!lastWasFreeform) { + incompleteFilterIndices = []; + } incompleteFilterIndices.push(token.startIndex); + lastWasFreeform = canonicalFilterFormats( + searchConfig.filtersMap.kvFilters[token.keyword]?.format, + ).includes('freeform'); } else { incompleteFilterIndices = []; } @@ -313,7 +325,7 @@ export function autocompleteTermSuggestions( caretIndex = (caretEndRegex.exec(query.slice(caretIndex))?.index || 0) + caretIndex; const queryUpToCaret = query.slice(0, caretIndex); - const lastFilters = findLastFilter(queryUpToCaret); + const lastFilters = findLastFilter(queryUpToCaret, searchConfig); if (!lastFilters) { return []; } @@ -386,6 +398,7 @@ export function makeFilterComplete( } } } + const searchCollator = cachedSearchCollator(searchConfig.language); // TODO: also search filter descriptions return (typed: string): string[] => { @@ -421,6 +434,7 @@ export function makeFilterComplete( // and "stat" matches "stat:" and "basestat:" const matchType = !mustStartWith && typedPlain.includes(':') ? 'startsWith' : 'includes'; + // TODO: Instead of using the plaintext, use the intl cached search collator? let suggestions = searchConfig.suggestions .filter( (word) => word.plainText.startsWith(mustStartWith) && word.plainText[matchType](typedPlain), @@ -429,19 +443,25 @@ export function makeFilterComplete( // TODO: sort this first?? it depends on term in one place + // TODO: We want to support is:gun autocompleting to is:shotgun + if (multiqueryTermsLookup[possibleKeyword] && filterNames.includes(possibleKeyword)) { // For multiquery filters, if the user has typed a + (or hasn't typed // anything) they're looking to add another query term, so offer to append // one. - const existingTerms = new Set( - (typedSegments[1] || '') - .split('+') - .filter((t) => multiqueryTermsLookup[possibleKeyword]!.includes(t)), + const typedArgs = (typedSegments[1] || '').split('+'); + // Existing, complete terms + const [existingTerms, possiblyIncompleteTerms] = partition(typedArgs, (t) => + multiqueryTermsLookup[possibleKeyword]!.includes(t), ); - const stem = `${typedSegments[0]}:${[...existingTerms].join('+')}${existingTerms.size ? '+' : ''}`; + const stem = `${typedSegments[0]}:${[...existingTerms].join('+')}${existingTerms.length ? '+' : ''}`; suggestions.push( ...filterMap(multiqueryTermsLookup[possibleKeyword], (t) => { - if (!existingTerms.has(t)) { + if ( + !existingTerms.includes(t) && + (possiblyIncompleteTerms.length === 0 || + possiblyIncompleteTerms.some((arg) => t.includes(arg))) + ) { const newTerm = stem + t; return { rawText: newTerm, diff --git a/src/app/search/search-config.ts b/src/app/search/search-config.ts index 5369ee6145..3551cdb9d4 100644 --- a/src/app/search/search-config.ts +++ b/src/app/search/search-config.ts @@ -16,6 +16,7 @@ export interface FiltersMap { kvFilters: Record>; } +// TODO: Let's not compute both rawText and plainText until we need both. Maybe we can use an i18n comparison instead? export interface Suggestion { /** The original suggestion text. */ rawText: string;