Skip to content

Commit

Permalink
feat: Enable search options
Browse files Browse the repository at this point in the history
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
  • Loading branch information
paultranvan committed Dec 24, 2024
1 parent eed0608 commit 4f07fec
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
135 changes: 103 additions & 32 deletions packages/cozy-dataproxy-lib/src/search/SearchEngine.spec.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,71 @@
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()
}))
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)
Expand All @@ -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 }
}
]

Expand All @@ -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']
}
]
Expand All @@ -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']
}
Expand All @@ -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([])
})
})
53 changes: 43 additions & 10 deletions packages/cozy-dataproxy-lib/src/search/SearchEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
APPS_DOCTYPE,
FILES_DOCTYPE,
CONTACTS_DOCTYPE,
DOCTYPE_ORDER,
DOCTYPE_DEFAULT_ORDER,
LIMIT_DOCTYPE_SEARCH,
SearchedDoctype,
SEARCHABLE_DOCTYPES
Expand All @@ -32,7 +32,8 @@ import {
SearchIndex,
SearchIndexes,
SearchResult,
isSearchedDoctype
isSearchedDoctype,
SearchOptions
} from './types'

const log = Minilog('🗂️ [Indexing]')
Expand Down Expand Up @@ -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[] = []
Expand All @@ -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)
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -455,6 +479,15 @@ export class SearchEngine {
}

limitSearchResults(searchResults: RawSearchResult[]): RawSearchResult[] {
return searchResults.slice(0, LIMIT_DOCTYPE_SEARCH)
const doctypesCount: Record<string, number> = {}
return searchResults.filter(result => {
const doctype = result.doctype
if (doctypesCount[doctype]) {
doctypesCount[doctype] += 1
} else {
doctypesCount[doctype] = 1
}
return doctypesCount[doctype] <= LIMIT_DOCTYPE_SEARCH
})
}
}
2 changes: 1 addition & 1 deletion packages/cozy-dataproxy-lib/src/search/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/cozy-dataproxy-lib/src/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CozyDoc> {
fields: string[]
Expand Down
2 changes: 1 addition & 1 deletion packages/cozy-dataproxy-lib/tests/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 4f07fec

Please sign in to comment.