diff --git a/.changeset/silly-badgers-hug.md b/.changeset/silly-badgers-hug.md new file mode 100644 index 00000000..0e46f868 --- /dev/null +++ b/.changeset/silly-badgers-hug.md @@ -0,0 +1,5 @@ +--- +"@meilisearch/autocomplete-client": minor +--- + +Add highlight metadata diff --git a/packages/autocomplete-client/__tests__/test.utils.ts b/packages/autocomplete-client/__tests__/test.utils.ts index 941bc4c3..1a306b4d 100644 --- a/packages/autocomplete-client/__tests__/test.utils.ts +++ b/packages/autocomplete-client/__tests__/test.utils.ts @@ -17,4 +17,59 @@ const meilisearchClient = new MeiliSearch({ apiKey: 'masterKey', }) +export const MOVIES = [ + { + id: 2, + title: 'Ariel', + overview: + "Taisto Kasurinen is a Finnish coal miner whose father has just committed suicide and who is framed for a crime he did not commit. In jail, he starts to dream about leaving the country and starting a new life. He escapes from prison but things don't go as planned...", + genres: ['Drama', 'Crime', 'Comedy'], + poster: 'https://image.tmdb.org/t/p/w500/ojDg0PGvs6R9xYFodRct2kdI6wC.jpg', + release_date: 593395200, + }, + { + id: 5, + title: 'Four Rooms', + overview: + "It's Ted the Bellhop's first night on the job...and the hotel's very unusual guests are about to place him in some outrageous predicaments. It seems that this evening's room service is serving up one unbelievable happening after another.", + genres: ['Crime', 'Comedy'], + poster: 'https://image.tmdb.org/t/p/w500/75aHn1NOYXh4M7L5shoeQ6NGykP.jpg', + release_date: 818467200, + }, + { + id: 6, + title: 'Judgment Night', + overview: + 'While racing to a boxing match, Frank, Mike, John and Rey get more than they bargained for. A wrong turn lands them directly in the path of Fallon, a vicious, wise-cracking drug lord. After accidentally witnessing Fallon murder a disloyal henchman, the four become his unwilling prey in a savage game of cat & mouse as they are mercilessly stalked through the urban jungle in this taut suspense drama', + genres: ['Action', 'Thriller', 'Crime'], + poster: 'https://image.tmdb.org/t/p/w500/rYFAvSPlQUCebayLcxyK79yvtvV.jpg', + release_date: 750643200, + }, + { + id: 11, + title: 'Star Wars', + overview: + 'Princess Leia is captured and held hostage by the evil Imperial forces in their effort to take over the galactic Empire. Venturesome Luke Skywalker and dashing captain Han Solo team together with the loveable robot duo R2-D2 and C-3PO to rescue the beautiful princess and restore peace and justice in the Empire.', + genres: ['Adventure', 'Action', 'Science Fiction'], + poster: 'https://image.tmdb.org/t/p/w500/6FfCtAuVAW8XJjZ7eWeLibRLWTw.jpg', + release_date: 233366400, + }, + { + id: 30, + title: 'Magnetic Rose', + overview: '', + genres: ['Animation', 'Science Fiction'], + poster: 'https://image.tmdb.org/t/p/w500/gSuHDeWemA1menrwfMRChnSmMVN.jpg', + release_date: 819676800, + }, + { + id: 24, + title: 'Kill Bill: Vol. 1', + overview: null, + genres: ['Action', 'Crime'], + poster: 'https://image.tmdb.org/t/p/w500/v7TaX8kXMXs5yFFGR41guUDNcnB.jpg', + release_date: 1065744000, + }, +] + export { HOST, API_KEY, searchClient, dataset, meilisearchClient } diff --git a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts index c7f279b2..8ced9f1a 100644 --- a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts +++ b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts @@ -1,57 +1,187 @@ import { fetchMeilisearchResults } from '../fetchMeilisearchResults' import { searchClient, - dataset, + MOVIES, meilisearchClient, } from '../../../__tests__/test.utils' +type Movie = (typeof MOVIES)[number] + +const INDEX_NAME = 'movies_fetch-meilisearch-results-test' +const FIRST_ITEM_ID = MOVIES[0].id +const SECOND_ITEM_ID = MOVIES[1].id + beforeAll(async () => { - await meilisearchClient.deleteIndex('testUid') - const task = await meilisearchClient.index('testUid').addDocuments(dataset) + await meilisearchClient.deleteIndex(INDEX_NAME) + const task = await meilisearchClient.index(INDEX_NAME).addDocuments(MOVIES) await meilisearchClient.waitForTask(task.taskUid) }) afterAll(async () => { - await meilisearchClient.deleteIndex('testUid') + await meilisearchClient.deleteIndex(INDEX_NAME) }) describe('fetchMeilisearchResults', () => { test('with default options', async () => { - const results = await fetchMeilisearchResults<(typeof dataset)[0]>({ + const results = await fetchMeilisearchResults({ searchClient, queries: [ { - indexName: 'testUid', + indexName: INDEX_NAME, query: '', }, ], }) - expect(results[0].hits[0].id).toEqual(1) - expect(results[0].hits[1].id).toEqual(2) + expect(results[0].hits[0].id).toEqual(FIRST_ITEM_ID) + expect(results[0].hits[1].id).toEqual(SECOND_ITEM_ID) }) - test('with custom search parameters', async () => { + test('with custom pagination', async () => { const results = await fetchMeilisearchResults({ searchClient, queries: [ { - indexName: 'testUid', - query: 'Hit', + indexName: INDEX_NAME, + query: '', params: { hitsPerPage: 1, - highlightPreTag: '', - highlightPostTag: '', - page: 1, + page: 1, // pages start at 0 + }, + }, + ], + }) + + expect(results[0].hits[0].id).toEqual(SECOND_ITEM_ID) + }) + + test('with custom highlight tags', async () => { + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: INDEX_NAME, + query: 'Ariel', + params: { + highlightPreTag: '', + highlightPostTag: '', + }, + }, + ], + }) + + expect(results[0].hits[0]._highlightResult?.title?.value).toEqual( + 'Ariel' + ) + }) + + test('highlight results contain highlighting metadata', async () => { + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: INDEX_NAME, + query: 'Ariel', + }, + ], + }) + + expect(results[0].hits[0]._highlightResult?.id?.fullyHighlighted).toEqual( + false + ) + expect(results[0].hits[0]._highlightResult?.id?.matchLevel).toEqual('none') + expect(results[0].hits[0]._highlightResult?.id?.matchedWords).toEqual([]) + expect(results[0].hits[0]._highlightResult?.id?.value).toEqual(String(2)) + }) + + test('highlight results contain fully highlighted match', async () => { + const pre = '' + const post = '' + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: INDEX_NAME, + query: 'Ariel', + params: { + highlightPreTag: pre, + highlightPostTag: post, + }, + }, + ], + }) + + expect(results[0].hits[0]._highlightResult?.title).toEqual({ + value: `${pre}Ariel${post}`, + fullyHighlighted: true, + matchLevel: 'full', + matchedWords: ['Ariel'], + }) + }) + + test('highlight results contains full match but not fully highlighted', async () => { + const pre = '' + const post = '' + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: INDEX_NAME, + query: 'Star', + params: { + highlightPreTag: pre, + highlightPostTag: post, }, }, ], }) - expect(results[0].hits[0].id).toEqual(2) - expect(results[0].hits[0]._highlightResult).toEqual({ - id: { value: '2' }, - label: { value: 'Hit 2' }, + expect(results[0].hits[0]._highlightResult?.title).toEqual({ + value: `${pre}Star${post} Wars`, + fullyHighlighted: false, + matchLevel: 'full', + matchedWords: ['Star'], + }) + }) + + test('highlight results contain partially highlighted match', async () => { + const pre = '' + const post = '' + const movie = MOVIES[0] + const results = await fetchMeilisearchResults({ + searchClient, + queries: [ + { + indexName: INDEX_NAME, + query: 'Tasto', // missing 'i' from 'Taisto' + params: { + highlightPreTag: pre, + highlightPostTag: post, + }, + }, + ], + }) + + expect(results[0].hits[0]._highlightResult?.overview).toEqual({ + // The first word of the overview is highlighted + value: `${pre}Taist${post}` + (movie.overview as string).slice(5), + fullyHighlighted: false, + matchLevel: 'partial', + matchedWords: ['Taist'], + }) + }) + + test('highlight results contain no match', async () => { + const results = await fetchMeilisearchResults({ + searchClient, + queries: [{ indexName: INDEX_NAME, query: '' }], + }) + + expect(results[0].hits[0]._highlightResult?.title).toEqual({ + value: 'Ariel', + fullyHighlighted: false, + matchLevel: 'none', + matchedWords: [], }) }) }) diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index 9c59a9a1..ebb7751e 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -8,6 +8,7 @@ import { HITS_PER_PAGE, } from '../constants' import { SearchClient as MeilisearchSearchClient } from '../types/SearchClient' +import { HighlightResult } from 'instantsearch.js/es/types/algoliasearch' interface SearchParams { /** @@ -17,7 +18,21 @@ interface SearchParams { /** * A list of queries to execute. */ - queries: AlgoliaMultipleQueriesQuery[] + queries: Array< + AlgoliaMultipleQueriesQuery & { + params?: { + highlightPreTag?: string + highlightPostTag?: string + } + } + > +} + +interface HighlightMetadata { + value: string + fullyHighlighted: boolean + matchLevel: 'none' | 'partial' | 'full' + matchedWords: string[] } export function fetchMeilisearchResults>({ @@ -28,7 +43,6 @@ export function fetchMeilisearchResults>({ .search( queries.map((searchParameters) => { const { params, ...headers } = searchParameters - return { ...headers, params: { @@ -42,7 +56,94 @@ export function fetchMeilisearchResults>({ ) .then( (response: Awaited>>) => { - return response.results + return response.results.map( + ( + result: AlgoliaSearchResponse, + resultsArrayIndex: number + ) => { + const query = queries[resultsArrayIndex] + return { + ...result, + hits: result.hits.map((hit) => ({ + ...hit, + _highlightResult: ( + Object.entries(hit?._highlightResult || {}) as Array< + | [keyof TRecord, { value: string }] + | [keyof TRecord, Array<{ value: string }>] // if the field is an array + > + ).reduce((acc, [field, highlightResult]) => { + return { + ...acc, + // if the field is an array, highlightResult is an array of objects + [field]: mapOneOrMany(highlightResult, (highlightResult) => + calculateHighlightMetadata( + query.query || '', + query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG, + query.params?.highlightPostTag || HIGHLIGHT_POST_TAG, + highlightResult.value + ) + ), + } + }, {} as HighlightResult), + })), + } + } + ) } ) } + +/** + * Calculate the highlight metadata for a given highlight value. + * @param query - The query string. + * @param preTag - The pre tag. + * @param postTag - The post tag. + * @param highlightValue - The highlight value response from Meilisearch. + * @returns The highlight metadata. + */ +function calculateHighlightMetadata( + query: string, + preTag: string, + postTag: string, + highlightValue: string +): HighlightMetadata { + // Extract all highlighted segments + const highlightRegex = new RegExp(`${preTag}(.*?)${postTag}`, 'g') + const matches: string[] = [] + let match + while ((match = highlightRegex.exec(highlightValue)) !== null) { + matches.push(match[1]) + } + + // Remove highlight tags to get the highlighted text without the tags + const cleanValue = highlightValue.replace( + new RegExp(`${preTag}|${postTag}`, 'g'), + '' + ) + + // Determine if the entire attribute is highlighted + // fullyHighlighted = true if cleanValue and the concatenation of all matched segments are identical + const highlightedText = matches.join('') + const fullyHighlighted = cleanValue === highlightedText + + // Determine match level: + // - 'none' if no matches + // - 'partial' if some matches but not fully highlighted + // - 'full' if the highlighted text is the entire field value content + let matchLevel: 'none' | 'partial' | 'full' = 'none' + if (matches.length > 0) { + matchLevel = cleanValue.includes(query) ? 'full' : 'partial' + } + + return { + value: highlightValue, + fullyHighlighted, + matchLevel, + matchedWords: matches, + } +} + +// Helper to apply a function to a single value or an array of values +function mapOneOrMany(value: T | T[], mapFn: (value: T) => U): U | U[] { + return Array.isArray(value) ? value.map(mapFn) : mapFn(value) +}