Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Enable search options #2685

Merged
merged 1 commit into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading