From 4f07fec7aaa0d00c3d3083e5972de5e5823f36d0 Mon Sep 17 00:00:00 2001 From: Paul Tran-Van Date: Tue, 24 Dec 2024 12:47:19 +0100 Subject: [PATCH] feat: Enable search options It is now possible to pass a `doctypes` options to the search, that serves two purposes: - Specify which doctypes should be searched - Specify the doctype order of the results --- .../src/dataproxy/DataProxyProvider.jsx | 5 +- .../src/search/SearchEngine.spec.js | 135 +++++++++++++----- .../src/search/SearchEngine.ts | 53 +++++-- .../cozy-dataproxy-lib/src/search/consts.ts | 2 +- .../cozy-dataproxy-lib/src/search/types.ts | 4 + .../cozy-dataproxy-lib/tests/jest.config.js | 2 +- 6 files changed, 154 insertions(+), 47 deletions(-) diff --git a/packages/cozy-dataproxy-lib/src/dataproxy/DataProxyProvider.jsx b/packages/cozy-dataproxy-lib/src/dataproxy/DataProxyProvider.jsx index 7bbd62e14e..2626a57539 100644 --- a/packages/cozy-dataproxy-lib/src/dataproxy/DataProxyProvider.jsx +++ b/packages/cozy-dataproxy-lib/src/dataproxy/DataProxyProvider.jsx @@ -16,7 +16,7 @@ export const useDataProxy = () => { return context } -export const DataProxyProvider = React.memo(({ children }) => { +export const DataProxyProvider = React.memo(({ children, options = {} }) => { const client = useClient() const [iframeUrl, setIframeUrl] = useState() const [dataProxy, setDataProxy] = useState() @@ -82,8 +82,7 @@ export const DataProxyProvider = React.memo(({ children }) => { const search = async search => { log.log('Send search query to DataProxy iframe') - - const result = await dataProxy.search(search) + const result = await dataProxy.search(search, options) return result } diff --git a/packages/cozy-dataproxy-lib/src/search/SearchEngine.spec.js b/packages/cozy-dataproxy-lib/src/search/SearchEngine.spec.js index 036aa9a92c..5dab30b077 100644 --- a/packages/cozy-dataproxy-lib/src/search/SearchEngine.spec.js +++ b/packages/cozy-dataproxy-lib/src/search/SearchEngine.spec.js @@ -1,11 +1,10 @@ import { createMockClient } from 'cozy-client' -import SearchEngine from './SearchEngine' -import { APPS_DOCTYPE, CONTACTS_DOCTYPE, FILES_DOCTYPE } from './consts' - +import { SearchEngine } from './SearchEngine' +import * as consts from './consts' jest.mock('cozy-client') jest.mock('flexsearch') -jest.mock('flexsearch/dist/module/lang/latin/balance') +jest.mock('flexsearch/dist/module/lang/latin/simple') jest.mock('./helpers/client', () => ({ getPouchLink: jest.fn() @@ -13,37 +12,60 @@ jest.mock('./helpers/client', () => ({ jest.mock('./helpers/getSearchEncoder', () => ({ getSearchEncoder: jest.fn() })) +jest.mock('./consts', () => ({ + LIMIT_DOCTYPE_SEARCH: 3, + SEARCH_SCHEMA: { + 'io.cozy.files': ['name', 'path'], + 'io.cozy.contacts': ['displayName', 'fullname'], + 'io.cozy.apps': ['slug', 'name'] + }, + DOCTYPE_DEFAULT_ORDER: { + 'io.cozy.apps': 0, + 'io.cozy.contacts': 1, + 'io.cozy.files': 2 + }, + FILES_DOCTYPE: 'io.cozy.files', + CONTACTS_DOCTYPE: 'io.cozy.contacts', + APPS_DOCTYPE: 'io.cozy.apps' +})) describe('sortSearchResults', () => { let searchEngine - beforeEach(() => { const client = createMockClient() searchEngine = new SearchEngine(client) }) - afterEach(() => { jest.clearAllMocks() }) it('should sort results by doctype order', () => { const searchResults = [ - { doctype: FILES_DOCTYPE, doc: { _type: FILES_DOCTYPE } }, - { doctype: APPS_DOCTYPE, doc: { _type: APPS_DOCTYPE } }, - { doctype: CONTACTS_DOCTYPE, doc: { _type: CONTACTS_DOCTYPE } } + { doctype: consts.FILES_DOCTYPE, doc: { _type: consts.FILES_DOCTYPE } }, + { doctype: consts.APPS_DOCTYPE, doc: { _type: consts.APPS_DOCTYPE } }, + { + doctype: consts.CONTACTS_DOCTYPE, + doc: { _type: consts.CONTACTS_DOCTYPE } + } ] const sortedResults = searchEngine.sortSearchResults(searchResults) - expect(sortedResults[0].doctype).toBe(APPS_DOCTYPE) - expect(sortedResults[1].doctype).toBe(CONTACTS_DOCTYPE) - expect(sortedResults[2].doctype).toBe(FILES_DOCTYPE) + expect(sortedResults[0].doctype).toBe(consts.APPS_DOCTYPE) + expect(sortedResults[1].doctype).toBe(consts.CONTACTS_DOCTYPE) + expect(sortedResults[2].doctype).toBe(consts.FILES_DOCTYPE) }) it('should sort apps by slug', () => { const searchResults = [ - { doctype: APPS_DOCTYPE, doc: { slug: 'appB', _type: APPS_DOCTYPE } }, - { doctype: APPS_DOCTYPE, doc: { slug: 'appA', _type: APPS_DOCTYPE } } + { + doctype: consts.APPS_DOCTYPE, + doc: { slug: 'appB', _type: consts.APPS_DOCTYPE } + }, + { + doctype: consts.APPS_DOCTYPE, + doc: { slug: 'appA', _type: consts.APPS_DOCTYPE } + } ] const sortedResults = searchEngine.sortSearchResults(searchResults) @@ -55,12 +77,12 @@ describe('sortSearchResults', () => { it('should sort contacts by displayName', () => { const searchResults = [ { - doctype: CONTACTS_DOCTYPE, - doc: { displayName: 'June', _type: CONTACTS_DOCTYPE } + doctype: consts.CONTACTS_DOCTYPE, + doc: { displayName: 'June', _type: consts.CONTACTS_DOCTYPE } }, { - doctype: CONTACTS_DOCTYPE, - doc: { displayName: 'Alice', _type: CONTACTS_DOCTYPE } + doctype: consts.CONTACTS_DOCTYPE, + doc: { displayName: 'Alice', _type: consts.CONTACTS_DOCTYPE } } ] @@ -73,18 +95,22 @@ describe('sortSearchResults', () => { it('should sort files by type and name', () => { const searchResults = [ { - doctype: FILES_DOCTYPE, - doc: { name: 'fileB', type: 'file', _type: FILES_DOCTYPE }, + doctype: consts.FILES_DOCTYPE, + doc: { name: 'fileB', type: 'file', _type: consts.FILES_DOCTYPE }, fields: ['name'] }, { - doctype: FILES_DOCTYPE, - doc: { name: 'fileA', type: 'file', _type: FILES_DOCTYPE }, + doctype: consts.FILES_DOCTYPE, + doc: { name: 'fileA', type: 'file', _type: consts.FILES_DOCTYPE }, fields: ['name'] }, { - doctype: FILES_DOCTYPE, - doc: { name: 'folderA', type: 'directory', _type: FILES_DOCTYPE }, + doctype: consts.FILES_DOCTYPE, + doc: { + name: 'folderA', + type: 'directory', + _type: consts.FILES_DOCTYPE + }, fields: ['name'] } ] @@ -99,42 +125,42 @@ describe('sortSearchResults', () => { it('should sort files first if they match on name, then path', () => { const searchResults = [ { - doctype: FILES_DOCTYPE, + doctype: consts.FILES_DOCTYPE, doc: { name: 'test11', path: 'test/test11', type: 'file', - _type: FILES_DOCTYPE + _type: consts.FILES_DOCTYPE }, fields: ['name'] }, { - doctype: FILES_DOCTYPE, + doctype: consts.FILES_DOCTYPE, doc: { name: 'test1', path: 'test/test1', type: 'file', - _type: FILES_DOCTYPE + _type: consts.FILES_DOCTYPE }, fields: ['name'] }, { - doctype: FILES_DOCTYPE, + doctype: consts.FILES_DOCTYPE, doc: { name: 'DirName1', path: 'test1/path', type: 'directory', - _type: FILES_DOCTYPE + _type: consts.FILES_DOCTYPE }, fields: ['path'] }, { - doctype: FILES_DOCTYPE, + doctype: consts.FILES_DOCTYPE, doc: { name: 'DirName2', path: 'test1/path', type: 'directory', - _type: FILES_DOCTYPE + _type: consts.FILES_DOCTYPE }, fields: ['name'] } @@ -148,3 +174,48 @@ describe('sortSearchResults', () => { expect(sortedResults[3].doc.name).toBe('DirName1') // Directory }) }) + +describe('limitSearchResults', () => { + let searchEngine + beforeEach(() => { + const client = createMockClient() + searchEngine = new SearchEngine(client) + }) + afterEach(() => { + jest.clearAllMocks() + }) + it('should return all results if doctype count is below or equal the limit', () => { + const searchResults = [ + { doctype: consts.FILES_DOCTYPE, id: 1 }, + { doctype: consts.FILES_DOCTYPE, id: 2 }, + { doctype: consts.FILES_DOCTYPE, id: 3 } + ] + + const filteredResults = searchEngine.limitSearchResults(searchResults) + expect(filteredResults).toEqual(searchResults) + }) + + it('should filter results exceeding the limit for a specific doctype', () => { + const searchResults = [ + { doctype: consts.FILES_DOCTYPE, id: 1 }, + { doctype: consts.FILES_DOCTYPE, id: 2 }, + { doctype: consts.FILES_DOCTYPE, id: 3 }, + { doctype: consts.FILES_DOCTYPE, id: 4 }, + { doctype: consts.CONTACTS_DOCTYPE, id: 5 } + ] + + const filteredResults = searchEngine.limitSearchResults(searchResults) + expect(filteredResults).toEqual([ + { doctype: consts.FILES_DOCTYPE, id: 1 }, + { doctype: consts.FILES_DOCTYPE, id: 2 }, + { doctype: consts.FILES_DOCTYPE, id: 3 }, + { doctype: consts.CONTACTS_DOCTYPE, id: 5 } + ]) + }) + + it('should return an empty array if input is empty', () => { + const searchResults = [] + const filteredResults = searchEngine.limitSearchResults(searchResults) + expect(filteredResults).toEqual([]) + }) +}) diff --git a/packages/cozy-dataproxy-lib/src/search/SearchEngine.ts b/packages/cozy-dataproxy-lib/src/search/SearchEngine.ts index d8692a3bac..4f4a00a1e9 100644 --- a/packages/cozy-dataproxy-lib/src/search/SearchEngine.ts +++ b/packages/cozy-dataproxy-lib/src/search/SearchEngine.ts @@ -9,7 +9,7 @@ import { APPS_DOCTYPE, FILES_DOCTYPE, CONTACTS_DOCTYPE, - DOCTYPE_ORDER, + DOCTYPE_DEFAULT_ORDER, LIMIT_DOCTYPE_SEARCH, SearchedDoctype, SEARCHABLE_DOCTYPES @@ -32,7 +32,8 @@ import { SearchIndex, SearchIndexes, SearchResult, - isSearchedDoctype + isSearchedDoctype, + SearchOptions } from './types' const log = Minilog('🗂️ [Indexing]') @@ -319,16 +320,19 @@ export class SearchEngine { return this.incrementalIndexation(doctype, searchIndex) } - search(query: string): SearchResult[] { + search(query: string, options: SearchOptions | undefined): SearchResult[] { if (!this.searchIndexes) { // TODO: What if the indexing is running but not finished yet? log.warn('[SEARCH] No search index available') return [] } - const allResults = this.searchOnIndexes(query) + const allResults = this.searchOnIndexes(query, options?.doctypes) const dedupResults = this.deduplicateAndFlatten(allResults) - const sortedResults = this.sortSearchResults(dedupResults) + const sortedResults = this.sortSearchResults( + dedupResults, + options?.doctypes + ) const results = this.limitSearchResults(sortedResults) const normResults: SearchResult[] = [] @@ -339,10 +343,19 @@ export class SearchEngine { return normResults.filter(res => res.title) } - searchOnIndexes(query: string): FlexSearchResultWithDoctype[] { + searchOnIndexes( + query: string, + searchOnDoctypes: string[] | undefined + ): FlexSearchResultWithDoctype[] { let searchResults: FlexSearchResultWithDoctype[] = [] for (const key in this.searchIndexes) { const doctype = key as SearchedDoctype // XXX - Should not be necessary + + if (searchOnDoctypes && !searchOnDoctypes.includes(doctype)) { + // Search only on specified doctypes + continue + } + const index = this.searchIndexes[doctype] if (!index) { log.warn('[SEARCH] No search index available for ', doctype) @@ -409,10 +422,21 @@ export class SearchEngine { return str1.localeCompare(str2, undefined, { numeric: true }) } - sortSearchResults(searchResults: RawSearchResult[]): RawSearchResult[] { + sortSearchResults( + searchResults: RawSearchResult[], + doctypesOrder: string[] | undefined + ): RawSearchResult[] { return searchResults.sort((a, b) => { - const doctypeComparison = - DOCTYPE_ORDER[a.doctype] - DOCTYPE_ORDER[b.doctype] + let doctypeComparison + if (doctypesOrder) { + doctypeComparison = + doctypesOrder.findIndex(dt => dt === a.doctype) - + doctypesOrder.findIndex(dt => dt === b.doctype) + } else { + doctypeComparison = + DOCTYPE_DEFAULT_ORDER[a.doctype] - DOCTYPE_DEFAULT_ORDER[b.doctype] + } + if (doctypeComparison !== 0) return doctypeComparison if ( a.doctype === APPS_DOCTYPE && @@ -455,6 +479,15 @@ export class SearchEngine { } limitSearchResults(searchResults: RawSearchResult[]): RawSearchResult[] { - return searchResults.slice(0, LIMIT_DOCTYPE_SEARCH) + const doctypesCount: Record = {} + return searchResults.filter(result => { + const doctype = result.doctype + if (doctypesCount[doctype]) { + doctypesCount[doctype] += 1 + } else { + doctypesCount[doctype] = 1 + } + return doctypesCount[doctype] <= LIMIT_DOCTYPE_SEARCH + }) } } diff --git a/packages/cozy-dataproxy-lib/src/search/consts.ts b/packages/cozy-dataproxy-lib/src/search/consts.ts index bbb79ee8fa..36f51a51e0 100644 --- a/packages/cozy-dataproxy-lib/src/search/consts.ts +++ b/packages/cozy-dataproxy-lib/src/search/consts.ts @@ -33,7 +33,7 @@ export const TRASH_DIR_ID = 'io.cozy.files.trash-dir' export const SHARED_DRIVES_DIR_ID = 'io.cozy.files.shared-drives-dir' export const LIMIT_DOCTYPE_SEARCH = 100 -export const DOCTYPE_ORDER = { +export const DOCTYPE_DEFAULT_ORDER = { [APPS_DOCTYPE]: 0, [CONTACTS_DOCTYPE]: 1, [FILES_DOCTYPE]: 2 diff --git a/packages/cozy-dataproxy-lib/src/search/types.ts b/packages/cozy-dataproxy-lib/src/search/types.ts index b7e2928428..6ba17d1295 100644 --- a/packages/cozy-dataproxy-lib/src/search/types.ts +++ b/packages/cozy-dataproxy-lib/src/search/types.ts @@ -35,6 +35,10 @@ export const isSearchedDoctype = ( return searchedDoctypes.includes(doctype) } +export interface SearchOptions { + doctypes: string[] // Specify which doctypes should be searched, and their order +} + export interface RawSearchResult extends FlexSearch.EnrichedDocumentSearchResultSetUnitResultUnit { fields: string[] diff --git a/packages/cozy-dataproxy-lib/tests/jest.config.js b/packages/cozy-dataproxy-lib/tests/jest.config.js index 865a292dad..438e638687 100644 --- a/packages/cozy-dataproxy-lib/tests/jest.config.js +++ b/packages/cozy-dataproxy-lib/tests/jest.config.js @@ -12,7 +12,7 @@ const config = { './src/search/helpers/getSearchEncoder.ts' ], rootDir: '../', - testMatch: ['./**/*.spec.{ts,tsx}'], + testMatch: ['./**/*.spec.{ts,tsx,js}'], coverageThreshold: { global: { branches: 80,