@digigov/text-search 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +3 -0
- package/.prettierrc.js +3 -0
- package/.rush/temp/operation/build/state.json +3 -0
- package/.rush/temp/package-deps_build.json +25 -0
- package/.rush/temp/package-deps_publint.json +25 -0
- package/.rush/temp/shrinkwrap-deps.json +158 -0
- package/CHANGELOG.json +79 -0
- package/CHANGELOG.md +8 -1
- package/dist/CHANGELOG.md +27 -0
- package/dist/README.md +70 -0
- package/{index.js → dist/index.js} +1 -1
- package/dist/package.json +21 -0
- package/dist/src/hook.spec.ts +289 -0
- package/dist/src/hook.ts +50 -0
- package/dist/src/index.ts +4 -0
- package/dist/src/search/__tests__/utils.spec.ts +73 -0
- package/dist/src/search/index.ts +3 -0
- package/dist/src/search/lang/gr/encoder.ts +27 -0
- package/dist/src/search/lang/gr/normalization-map.ts +100 -0
- package/dist/src/search/search-index.ts +103 -0
- package/dist/src/search/utils.ts +72 -0
- package/dist/src/types.ts +65 -0
- package/dist/test-utils/data.json +552 -0
- package/package.json +28 -6
- package/src/search/lang/gr/encoder.ts +1 -1
- package/text-search.build.log +13 -0
- package/text-search.publint.log +15 -0
- package/tsconfig.json +44 -0
- /package/{LICENSE → dist/LICENSE} +0 -0
- /package/{cjs → dist/cjs}/hook/index.js +0 -0
- /package/{cjs → dist/cjs}/hook.spec/index.js +0 -0
- /package/{cjs → dist/cjs}/index.js +0 -0
- /package/{cjs → dist/cjs}/search/__tests__/utils.spec/index.js +0 -0
- /package/{cjs → dist/cjs}/search/index.js +0 -0
- /package/{cjs → dist/cjs}/search/lang/gr/encoder/index.js +0 -0
- /package/{cjs → dist/cjs}/search/lang/gr/normalization-map/index.js +0 -0
- /package/{cjs → dist/cjs}/search/search-index/index.js +0 -0
- /package/{cjs → dist/cjs}/search/utils/index.js +0 -0
- /package/{cjs → dist/cjs}/test-utils/data.json +0 -0
- /package/{cjs → dist/cjs}/types/index.js +0 -0
- /package/{hook → dist/hook}/index.js +0 -0
- /package/{hook → dist/hook}/package.json +0 -0
- /package/{hook.d.ts → dist/hook.d.ts} +0 -0
- /package/{hook.spec → dist/hook.spec}/index.js +0 -0
- /package/{hook.spec → dist/hook.spec}/package.json +0 -0
- /package/{hook.spec.d.ts → dist/hook.spec.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{search → dist/search}/__tests__/utils.spec/index.js +0 -0
- /package/{search → dist/search}/__tests__/utils.spec/package.json +0 -0
- /package/{search → dist/search}/__tests__/utils.spec.d.ts +0 -0
- /package/{search → dist/search}/index.d.ts +0 -0
- /package/{search → dist/search}/index.js +0 -0
- /package/{search → dist/search}/lang/gr/encoder/index.js +0 -0
- /package/{search → dist/search}/lang/gr/encoder/package.json +0 -0
- /package/{search → dist/search}/lang/gr/encoder.d.ts +0 -0
- /package/{search → dist/search}/lang/gr/normalization-map/index.js +0 -0
- /package/{search → dist/search}/lang/gr/normalization-map/package.json +0 -0
- /package/{search → dist/search}/lang/gr/normalization-map.d.ts +0 -0
- /package/{search → dist/search}/package.json +0 -0
- /package/{search → dist/search}/search-index/index.js +0 -0
- /package/{search → dist/search}/search-index/package.json +0 -0
- /package/{search → dist/search}/search-index.d.ts +0 -0
- /package/{search → dist/search}/utils/index.js +0 -0
- /package/{search → dist/search}/utils/package.json +0 -0
- /package/{search → dist/search}/utils.d.ts +0 -0
- /package/{test-utils → dist/src/test-utils}/data.json +0 -0
- /package/{types → dist/types}/index.js +0 -0
- /package/{types → dist/types}/package.json +0 -0
- /package/{types.d.ts → dist/types.d.ts} +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-hooks';
|
|
2
|
+
import useSearch from './hook';
|
|
3
|
+
import documents from './test-utils/data.json';
|
|
4
|
+
|
|
5
|
+
describe('loading', () => {
|
|
6
|
+
it('should start when searching', async () => {
|
|
7
|
+
const { result } = renderHook(() => useSearch(documents, 'Anastasia'));
|
|
8
|
+
expect(result.current.loading).toBe(false);
|
|
9
|
+
act(() => result.current.search());
|
|
10
|
+
expect(result.current.loading).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it('should stop after search is completed', async () => {
|
|
13
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
14
|
+
useSearch(documents, 'Anastasia')
|
|
15
|
+
);
|
|
16
|
+
expect(result.current.loading).toBe(false);
|
|
17
|
+
act(() => result.current.search());
|
|
18
|
+
expect(result.current.loading).toBe(true);
|
|
19
|
+
await waitForNextUpdate();
|
|
20
|
+
expect(result.current.loading).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('reset', () => {
|
|
25
|
+
it('should change the returned result back to the initial data', async () => {
|
|
26
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
27
|
+
useSearch(documents, 'Anastasia')
|
|
28
|
+
);
|
|
29
|
+
expect(result.current.loading).toBe(false);
|
|
30
|
+
act(() => result.current.search());
|
|
31
|
+
expect(result.current.loading).toBe(true);
|
|
32
|
+
expect(result.current.data).toEqual(documents);
|
|
33
|
+
await waitForNextUpdate();
|
|
34
|
+
expect(result.current.loading).toBe(false);
|
|
35
|
+
expect(result.current.data.length).toBe(1);
|
|
36
|
+
|
|
37
|
+
act(() => result.current.reset());
|
|
38
|
+
expect(result.current.data).toEqual(documents);
|
|
39
|
+
expect(result.current.loading).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('search', () => {
|
|
44
|
+
it('should return the initial data when no query is passed', async () => {
|
|
45
|
+
const { result } = renderHook(() => useSearch(documents));
|
|
46
|
+
expect(result.current.loading).toBe(false);
|
|
47
|
+
act(() => result.current.search());
|
|
48
|
+
expect(result.current.loading).toBe(false);
|
|
49
|
+
expect(result.current.data).toEqual(documents);
|
|
50
|
+
});
|
|
51
|
+
it('should return an empty array when there are no relevant items', async () => {
|
|
52
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
53
|
+
useSearch(documents, 'fajsgsdgsdgsd')
|
|
54
|
+
);
|
|
55
|
+
expect(result.current.loading).toBe(false);
|
|
56
|
+
act(() => result.current.search());
|
|
57
|
+
expect(result.current.loading).toBe(true);
|
|
58
|
+
expect(result.current.data).toEqual(documents);
|
|
59
|
+
await waitForNextUpdate();
|
|
60
|
+
expect(result.current.loading).toBe(false);
|
|
61
|
+
expect(result.current.data).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
it('should return an empty array when relevant field is not indexed', async () => {
|
|
64
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
65
|
+
useSearch(documents, 'Anastasia', {
|
|
66
|
+
indexing: {
|
|
67
|
+
fields: ['lastName'], // missing 'firstName'
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
expect(result.current.loading).toBe(false);
|
|
72
|
+
act(() => result.current.search());
|
|
73
|
+
expect(result.current.loading).toBe(true);
|
|
74
|
+
expect(result.current.data).toEqual(documents);
|
|
75
|
+
await waitForNextUpdate();
|
|
76
|
+
expect(result.current.loading).toBe(false);
|
|
77
|
+
expect(result.current.data).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
it('should return a single result when there is only one relevant item', async () => {
|
|
80
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
81
|
+
useSearch(documents, 'Anastasia')
|
|
82
|
+
);
|
|
83
|
+
expect(result.current.loading).toBe(false);
|
|
84
|
+
act(() => result.current.search());
|
|
85
|
+
expect(result.current.loading).toBe(true);
|
|
86
|
+
expect(result.current.data).toEqual(documents);
|
|
87
|
+
await waitForNextUpdate();
|
|
88
|
+
expect(result.current.loading).toBe(false);
|
|
89
|
+
expect(result.current.data.length).toBe(1);
|
|
90
|
+
|
|
91
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
92
|
+
typeof result.current.data[0]
|
|
93
|
+
>;
|
|
94
|
+
expect(firstResult).toBeDefined();
|
|
95
|
+
expect(Object.values(firstResult).values()).toContain('Anastasia');
|
|
96
|
+
});
|
|
97
|
+
it('should return multiple results when there are multiple relevant items', async () => {
|
|
98
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
99
|
+
useSearch(documents, 'Kari')
|
|
100
|
+
);
|
|
101
|
+
expect(result.current.loading).toBe(false);
|
|
102
|
+
act(() => result.current.search());
|
|
103
|
+
expect(result.current.loading).toBe(true);
|
|
104
|
+
expect(result.current.data).toEqual(documents);
|
|
105
|
+
await waitForNextUpdate();
|
|
106
|
+
expect(result.current.loading).toBe(false);
|
|
107
|
+
expect(result.current.data.length).toBeGreaterThanOrEqual(2);
|
|
108
|
+
|
|
109
|
+
result.current.data.forEach((item) => {
|
|
110
|
+
expect(item).toBeDefined();
|
|
111
|
+
expect(Object.values(item).values()).toContain('Kari');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
it('should work with a partial query', async () => {
|
|
115
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
116
|
+
useSearch(documents, 'anast')
|
|
117
|
+
);
|
|
118
|
+
expect(result.current.loading).toBe(false);
|
|
119
|
+
act(() => result.current.search());
|
|
120
|
+
expect(result.current.loading).toBe(true);
|
|
121
|
+
expect(result.current.data).toEqual(documents);
|
|
122
|
+
await waitForNextUpdate();
|
|
123
|
+
expect(result.current.loading).toBe(false);
|
|
124
|
+
expect(result.current.data.length).toBe(1);
|
|
125
|
+
|
|
126
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
127
|
+
typeof result.current.data[0]
|
|
128
|
+
>;
|
|
129
|
+
expect(firstResult).toBeDefined();
|
|
130
|
+
expect(Object.values(firstResult).values()).toContain('Anastasia');
|
|
131
|
+
});
|
|
132
|
+
it('should work with nested field if not explicitly stated', async () => {
|
|
133
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
134
|
+
useSearch(documents, 'Titanic')
|
|
135
|
+
);
|
|
136
|
+
expect(result.current.loading).toBe(false);
|
|
137
|
+
act(() => result.current.search());
|
|
138
|
+
expect(result.current.loading).toBe(true);
|
|
139
|
+
expect(result.current.data).toEqual(documents);
|
|
140
|
+
await waitForNextUpdate();
|
|
141
|
+
expect(result.current.loading).toBe(false);
|
|
142
|
+
expect(result.current.data.length).toBe(1);
|
|
143
|
+
|
|
144
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
145
|
+
typeof result.current.data[0]
|
|
146
|
+
>;
|
|
147
|
+
expect(firstResult).toBeDefined();
|
|
148
|
+
expect(Object.values(firstResult.address).values()).toContain('Titanic');
|
|
149
|
+
});
|
|
150
|
+
it('should work with nested field if explicitly stated', async () => {
|
|
151
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
152
|
+
useSearch(documents, 'Titanic', {
|
|
153
|
+
indexing: {
|
|
154
|
+
fields: ['address.city'],
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
expect(result.current.loading).toBe(false);
|
|
159
|
+
act(() => result.current.search());
|
|
160
|
+
expect(result.current.loading).toBe(true);
|
|
161
|
+
expect(result.current.data).toEqual(documents);
|
|
162
|
+
await waitForNextUpdate();
|
|
163
|
+
expect(result.current.loading).toBe(false);
|
|
164
|
+
expect(result.current.data.length).toBe(1);
|
|
165
|
+
|
|
166
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
167
|
+
typeof result.current.data[0]
|
|
168
|
+
>;
|
|
169
|
+
expect(firstResult).toBeDefined();
|
|
170
|
+
expect(Object.values(firstResult.address).values()).toContain('Titanic');
|
|
171
|
+
});
|
|
172
|
+
it('should work with nested ID', async () => {
|
|
173
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
174
|
+
useSearch(documents, 'Titanic', {
|
|
175
|
+
indexing: {
|
|
176
|
+
idKey: 'address.street',
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
expect(result.current.loading).toBe(false);
|
|
181
|
+
act(() => result.current.search());
|
|
182
|
+
expect(result.current.loading).toBe(true);
|
|
183
|
+
expect(result.current.data).toEqual(documents);
|
|
184
|
+
await waitForNextUpdate();
|
|
185
|
+
expect(result.current.loading).toBe(false);
|
|
186
|
+
expect(result.current.data.length).toBe(1);
|
|
187
|
+
|
|
188
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
189
|
+
typeof result.current.data[0]
|
|
190
|
+
>;
|
|
191
|
+
expect(firstResult).toBeDefined();
|
|
192
|
+
expect(Object.values(firstResult.address).values()).toContain('Titanic');
|
|
193
|
+
});
|
|
194
|
+
it('should work with indexed Greek characters and Latin query', async () => {
|
|
195
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
196
|
+
useSearch(documents, 'Markos')
|
|
197
|
+
);
|
|
198
|
+
expect(result.current.loading).toBe(false);
|
|
199
|
+
act(() => result.current.search());
|
|
200
|
+
expect(result.current.loading).toBe(true);
|
|
201
|
+
expect(result.current.data).toEqual(documents);
|
|
202
|
+
await waitForNextUpdate();
|
|
203
|
+
expect(result.current.loading).toBe(false);
|
|
204
|
+
expect(result.current.data.length).toBe(1);
|
|
205
|
+
|
|
206
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
207
|
+
typeof result.current.data[0]
|
|
208
|
+
>;
|
|
209
|
+
expect(firstResult).toBeDefined();
|
|
210
|
+
expect(Object.values(firstResult).values()).toContain('Μάρκος');
|
|
211
|
+
});
|
|
212
|
+
it('should work with indexed Greek characters and Greek query', async () => {
|
|
213
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
214
|
+
useSearch(documents, 'Μάρκος')
|
|
215
|
+
);
|
|
216
|
+
expect(result.current.loading).toBe(false);
|
|
217
|
+
act(() => result.current.search());
|
|
218
|
+
expect(result.current.loading).toBe(true);
|
|
219
|
+
expect(result.current.data).toEqual(documents);
|
|
220
|
+
await waitForNextUpdate();
|
|
221
|
+
expect(result.current.loading).toBe(false);
|
|
222
|
+
expect(result.current.data.length).toBe(1);
|
|
223
|
+
|
|
224
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
225
|
+
typeof result.current.data[0]
|
|
226
|
+
>;
|
|
227
|
+
expect(firstResult).toBeDefined();
|
|
228
|
+
expect(Object.values(firstResult).values()).toContain('Μάρκος');
|
|
229
|
+
});
|
|
230
|
+
it('should react to changes in the documents list', async () => {
|
|
231
|
+
const emptyDocuments: typeof documents = [];
|
|
232
|
+
const { result, rerender, waitForNextUpdate } = renderHook(
|
|
233
|
+
({ documents }) => useSearch(documents, 'Anastasia'),
|
|
234
|
+
{
|
|
235
|
+
initialProps: { documents: emptyDocuments },
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
expect(result.current.loading).toBe(false);
|
|
239
|
+
act(() => result.current.search());
|
|
240
|
+
expect(result.current.loading).toBe(true);
|
|
241
|
+
await waitForNextUpdate();
|
|
242
|
+
expect(result.current.loading).toBe(false);
|
|
243
|
+
expect(result.current.data).toEqual([]);
|
|
244
|
+
|
|
245
|
+
rerender({ documents });
|
|
246
|
+
|
|
247
|
+
expect(result.current.loading).toBe(false);
|
|
248
|
+
expect(result.current.data).toEqual([]);
|
|
249
|
+
act(() => result.current.search());
|
|
250
|
+
expect(result.current.loading).toBe(true);
|
|
251
|
+
expect(result.current.data).toEqual([]);
|
|
252
|
+
await waitForNextUpdate();
|
|
253
|
+
expect(result.current.loading).toBe(false);
|
|
254
|
+
expect(result.current.data.length).toBe(1);
|
|
255
|
+
|
|
256
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
257
|
+
typeof result.current.data[0]
|
|
258
|
+
>;
|
|
259
|
+
expect(firstResult).toBeDefined();
|
|
260
|
+
expect(Object.values(firstResult).values()).toContain('Anastasia');
|
|
261
|
+
});
|
|
262
|
+
it('should react to changes in the search query', async () => {
|
|
263
|
+
const { result, rerender, waitForNextUpdate } = renderHook(
|
|
264
|
+
({ query }) => useSearch(documents, query),
|
|
265
|
+
{
|
|
266
|
+
initialProps: { query: '' },
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
expect(result.current.loading).toBe(false);
|
|
270
|
+
expect(result.current.data).toEqual(documents);
|
|
271
|
+
|
|
272
|
+
rerender({ query: 'Anastasia' });
|
|
273
|
+
|
|
274
|
+
expect(result.current.loading).toBe(false);
|
|
275
|
+
expect(result.current.data).toEqual(documents);
|
|
276
|
+
act(() => result.current.search());
|
|
277
|
+
expect(result.current.loading).toBe(true);
|
|
278
|
+
expect(result.current.data).toEqual(documents);
|
|
279
|
+
await waitForNextUpdate();
|
|
280
|
+
expect(result.current.loading).toBe(false);
|
|
281
|
+
expect(result.current.data.length).toBe(1);
|
|
282
|
+
|
|
283
|
+
const firstResult = result.current.data[0] as NonNullable<
|
|
284
|
+
typeof result.current.data[0]
|
|
285
|
+
>;
|
|
286
|
+
expect(firstResult).toBeDefined();
|
|
287
|
+
expect(Object.values(firstResult).values()).toContain('Anastasia');
|
|
288
|
+
});
|
|
289
|
+
});
|
package/dist/src/hook.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import SearchIndex from './search';
|
|
3
|
+
import { UseSearchOptions } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook for searching through a list of documents
|
|
7
|
+
*
|
|
8
|
+
* Returns a list of documents that match the search query.
|
|
9
|
+
* If no query is provided, it returns the original list of documents.
|
|
10
|
+
*
|
|
11
|
+
* @param documents - The list of documents to search through
|
|
12
|
+
* @param options - Options for configuring the search index
|
|
13
|
+
* @param query - The search query
|
|
14
|
+
*
|
|
15
|
+
* @typeParam T - The type of the data in the documents list
|
|
16
|
+
*/
|
|
17
|
+
export default function useSearch<T extends Record<string, any>>(
|
|
18
|
+
documents: T[],
|
|
19
|
+
query?: string,
|
|
20
|
+
options?: UseSearchOptions<T>
|
|
21
|
+
) {
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [data, setData] = useState(documents);
|
|
24
|
+
|
|
25
|
+
const indexing = options?.indexing;
|
|
26
|
+
|
|
27
|
+
const index = useMemo(() => new SearchIndex(documents, indexing), [
|
|
28
|
+
documents,
|
|
29
|
+
indexing,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const search = useCallback(() => {
|
|
33
|
+
if (query) {
|
|
34
|
+
setLoading(true);
|
|
35
|
+
index
|
|
36
|
+
.searchAsync(documents, query)
|
|
37
|
+
.then((data) => setData(data))
|
|
38
|
+
.finally(() => setLoading(false));
|
|
39
|
+
} else {
|
|
40
|
+
setData(documents);
|
|
41
|
+
}
|
|
42
|
+
}, [query, index, documents]);
|
|
43
|
+
|
|
44
|
+
const reset = useCallback(() => {
|
|
45
|
+
setData(documents);
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}, [documents]);
|
|
48
|
+
|
|
49
|
+
return { data, loading, search, reset };
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SimpleDocumentSearchResultSetUnit as ResultType } from 'flexsearch';
|
|
2
|
+
import { findItemsByIds, getAllItemKeys, getResultIds } from '../utils';
|
|
3
|
+
|
|
4
|
+
describe('utils', () => {
|
|
5
|
+
describe('getResultIds', () => {
|
|
6
|
+
it('should return the ids of the results without duplicates', () => {
|
|
7
|
+
const result: ResultType[] = [
|
|
8
|
+
{ field: 'title', result: [1, 2] },
|
|
9
|
+
{ field: 'description', result: [2, 3] },
|
|
10
|
+
{ field: 'tag', result: [1, 2, 3, 4] },
|
|
11
|
+
];
|
|
12
|
+
expect(getResultIds(result)).toEqual([1, 2, 3, 4]);
|
|
13
|
+
});
|
|
14
|
+
it('should return an empty array if there are no results', () => {
|
|
15
|
+
const result: ResultType[] = [];
|
|
16
|
+
expect(getResultIds(result)).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('findItemsByIds', () => {
|
|
21
|
+
it('should return the items that match the given ids', () => {
|
|
22
|
+
const items = [
|
|
23
|
+
{ id: 1, name: 'Anastasia' },
|
|
24
|
+
{ id: 2, name: 'Bob' },
|
|
25
|
+
{ id: 3, name: 'Cindy' },
|
|
26
|
+
];
|
|
27
|
+
const ids = [1, 3];
|
|
28
|
+
expect(findItemsByIds(items, ids, 'id')).toEqual([
|
|
29
|
+
{ id: 1, name: 'Anastasia' },
|
|
30
|
+
{ id: 3, name: 'Cindy' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
it('should return an empty array if there are no items', () => {
|
|
34
|
+
const items: Record<string, any>[] = [];
|
|
35
|
+
const ids = [1, 3];
|
|
36
|
+
expect(findItemsByIds(items, ids, 'id')).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
it('should return an empty array if there are no ids', () => {
|
|
39
|
+
const items = [
|
|
40
|
+
{ id: 1, name: 'Anastasia' },
|
|
41
|
+
{ id: 2, name: 'Bob' },
|
|
42
|
+
{ id: 3, name: 'Cindy' },
|
|
43
|
+
];
|
|
44
|
+
const ids: number[] = [];
|
|
45
|
+
expect(findItemsByIds(items, ids, 'id')).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getAllItemKeys', () => {
|
|
50
|
+
it('should return all the keys for the given item', () => {
|
|
51
|
+
const item = {
|
|
52
|
+
id: 1,
|
|
53
|
+
name: 'Anastasia',
|
|
54
|
+
address: {
|
|
55
|
+
street: '123 Main St',
|
|
56
|
+
city: 'New York',
|
|
57
|
+
state: 'NY',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
expect(getAllItemKeys(item)).toEqual([
|
|
61
|
+
'id',
|
|
62
|
+
'name',
|
|
63
|
+
'address.street',
|
|
64
|
+
'address.city',
|
|
65
|
+
'address.state',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
it('should return an empty array if item is an empty object', () => {
|
|
69
|
+
const item: Record<string, any> = {};
|
|
70
|
+
expect(getAllItemKeys(item)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { greekToGreeklishMap } from './normalization-map';
|
|
2
|
+
|
|
3
|
+
const whitespaceRegex = /[\p{Z}\p{S}\p{P}\p{C}]+/u;
|
|
4
|
+
const diacriticsRegex = /[\u0300-\u036f]/g;
|
|
5
|
+
const greekCharsRegex = new RegExp(
|
|
6
|
+
Object.keys(greekToGreeklishMap).join('|'),
|
|
7
|
+
'igu'
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Encode a string containing greek characters to greeklish
|
|
12
|
+
*
|
|
13
|
+
* This function is used to encode and tokenize a string containing greek characters.
|
|
14
|
+
* It replaces greek characters with their latin counterparts, while also removing
|
|
15
|
+
* any diacritics.
|
|
16
|
+
*
|
|
17
|
+
* @param str - The string to encode
|
|
18
|
+
* @returns An array of encoded words
|
|
19
|
+
*/
|
|
20
|
+
export function encodeGreek(str: string) {
|
|
21
|
+
return ('' + str)
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.normalize('NFD')
|
|
24
|
+
.replace(diacriticsRegex, '')
|
|
25
|
+
.replace(greekCharsRegex, (match) => (greekToGreeklishMap as any)[match])
|
|
26
|
+
.split(whitespaceRegex);
|
|
27
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const greekToGreeklishMap = {
|
|
2
|
+
ΓΧ: 'GX',
|
|
3
|
+
γχ: 'gx',
|
|
4
|
+
ΤΘ: 'T8',
|
|
5
|
+
τθ: 't8',
|
|
6
|
+
θη: '8h',
|
|
7
|
+
Θη: '8h',
|
|
8
|
+
ΘΗ: '8H',
|
|
9
|
+
αυ: 'au',
|
|
10
|
+
Αυ: 'Au',
|
|
11
|
+
ΑΥ: 'AY',
|
|
12
|
+
ευ: 'eu',
|
|
13
|
+
εύ: 'eu',
|
|
14
|
+
εϋ: 'ey',
|
|
15
|
+
εΰ: 'ey',
|
|
16
|
+
Ευ: 'Eu',
|
|
17
|
+
Εύ: 'Eu',
|
|
18
|
+
Εϋ: 'Ey',
|
|
19
|
+
Εΰ: 'Ey',
|
|
20
|
+
ΕΥ: 'EY',
|
|
21
|
+
ου: 'ou',
|
|
22
|
+
ού: 'ou',
|
|
23
|
+
οϋ: 'oy',
|
|
24
|
+
οΰ: 'oy',
|
|
25
|
+
Ου: 'Ou',
|
|
26
|
+
Ού: 'Ou',
|
|
27
|
+
Οϋ: 'Oy',
|
|
28
|
+
Οΰ: 'Oy',
|
|
29
|
+
ΟΥ: 'OY',
|
|
30
|
+
Α: 'A',
|
|
31
|
+
α: 'a',
|
|
32
|
+
ά: 'a',
|
|
33
|
+
Ά: 'A',
|
|
34
|
+
Β: 'B',
|
|
35
|
+
β: 'b',
|
|
36
|
+
Γ: 'G',
|
|
37
|
+
γ: 'g',
|
|
38
|
+
Δ: 'D',
|
|
39
|
+
δ: 'd',
|
|
40
|
+
Ε: 'E',
|
|
41
|
+
ε: 'e',
|
|
42
|
+
έ: 'e',
|
|
43
|
+
Έ: 'E',
|
|
44
|
+
Ζ: 'Z',
|
|
45
|
+
ζ: 'z',
|
|
46
|
+
Η: 'H',
|
|
47
|
+
η: 'h',
|
|
48
|
+
ή: 'h',
|
|
49
|
+
Ή: 'H',
|
|
50
|
+
Θ: 'TH',
|
|
51
|
+
θ: 'th',
|
|
52
|
+
Ι: 'I',
|
|
53
|
+
Ϊ: 'I',
|
|
54
|
+
ι: 'i',
|
|
55
|
+
ί: 'i',
|
|
56
|
+
ΐ: 'i',
|
|
57
|
+
ϊ: 'i',
|
|
58
|
+
Ί: 'I',
|
|
59
|
+
Κ: 'K',
|
|
60
|
+
κ: 'k',
|
|
61
|
+
Λ: 'L',
|
|
62
|
+
λ: 'l',
|
|
63
|
+
Μ: 'M',
|
|
64
|
+
μ: 'm',
|
|
65
|
+
Ν: 'N',
|
|
66
|
+
ν: 'n',
|
|
67
|
+
Ξ: 'KS',
|
|
68
|
+
ξ: 'ks',
|
|
69
|
+
Ο: 'O',
|
|
70
|
+
ο: 'o',
|
|
71
|
+
Ό: 'O',
|
|
72
|
+
ό: 'o',
|
|
73
|
+
Π: 'P',
|
|
74
|
+
π: 'p',
|
|
75
|
+
Ρ: 'R',
|
|
76
|
+
ρ: 'r',
|
|
77
|
+
Σ: 'S',
|
|
78
|
+
σ: 's',
|
|
79
|
+
Τ: 'T',
|
|
80
|
+
τ: 't',
|
|
81
|
+
Υ: 'Y',
|
|
82
|
+
Ύ: 'Y',
|
|
83
|
+
Ϋ: 'Y',
|
|
84
|
+
ΰ: 'y',
|
|
85
|
+
ύ: 'y',
|
|
86
|
+
ϋ: 'y',
|
|
87
|
+
υ: 'y',
|
|
88
|
+
Φ: 'F',
|
|
89
|
+
φ: 'f',
|
|
90
|
+
Χ: 'X',
|
|
91
|
+
χ: 'x',
|
|
92
|
+
Ψ: 'Ps',
|
|
93
|
+
ψ: 'ps',
|
|
94
|
+
Ω: 'w',
|
|
95
|
+
ω: 'w',
|
|
96
|
+
Ώ: 'w',
|
|
97
|
+
ώ: 'w',
|
|
98
|
+
ς: 's',
|
|
99
|
+
';': '?',
|
|
100
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Document } from 'flexsearch';
|
|
2
|
+
import type { SearchIndexOptions } from '../types';
|
|
3
|
+
import { encodeGreek } from './lang/gr/encoder';
|
|
4
|
+
import { findItemsByIds, getAllItemKeys, getResultIds } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrapper class around third party index and search library
|
|
8
|
+
*
|
|
9
|
+
* @typeParam T - The type of the data that will be indexed
|
|
10
|
+
*/
|
|
11
|
+
export class SearchIndex<T extends Record<string, any>> {
|
|
12
|
+
/**
|
|
13
|
+
* Instance of the third party library's index
|
|
14
|
+
*/
|
|
15
|
+
private index: Document<T>;
|
|
16
|
+
private idKey: string;
|
|
17
|
+
|
|
18
|
+
constructor(items: T[], options?: SearchIndexOptions<T>) {
|
|
19
|
+
this.idKey = 'id';
|
|
20
|
+
if (options && options.idKey) {
|
|
21
|
+
this.idKey = options.idKey.replace('.', ':');
|
|
22
|
+
} else if (!(items[0] && 'id' in items[0])) {
|
|
23
|
+
items = items.map((item, index) => ({ ...item, id: index }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let fields: string[] = [];
|
|
27
|
+
if (options && options.fields) {
|
|
28
|
+
fields = options.fields;
|
|
29
|
+
} else if (items[0]) {
|
|
30
|
+
fields = getAllItemKeys(items[0]);
|
|
31
|
+
}
|
|
32
|
+
fields = fields
|
|
33
|
+
.filter((field) => field !== this.idKey)
|
|
34
|
+
.map((field) => field.replace('.', ':'));
|
|
35
|
+
|
|
36
|
+
this.index = new Document<T>({
|
|
37
|
+
document: {
|
|
38
|
+
id: this.idKey,
|
|
39
|
+
index: fields,
|
|
40
|
+
},
|
|
41
|
+
tokenize: 'forward',
|
|
42
|
+
worker: options?.enableWorker,
|
|
43
|
+
encode: encodeGreek,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.addAll(items);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add an item to the index
|
|
51
|
+
*
|
|
52
|
+
* @param item - The item to add to the index
|
|
53
|
+
*/
|
|
54
|
+
public add(item: T) {
|
|
55
|
+
this.index.add(item);
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add multiple items to the index
|
|
61
|
+
*
|
|
62
|
+
* @param items - The list of items to add to the index
|
|
63
|
+
*/
|
|
64
|
+
public addAll(items: T[]) {
|
|
65
|
+
items.forEach((item) => this.index.add(item));
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Search the index for the given term
|
|
71
|
+
*
|
|
72
|
+
* @param documents - The list of documents to search
|
|
73
|
+
* @param searchTerm - The term to search for
|
|
74
|
+
* @returns The list of items that match the search term
|
|
75
|
+
*/
|
|
76
|
+
public search(documents: T[], searchTerm: string) {
|
|
77
|
+
const res = this.index.search(searchTerm);
|
|
78
|
+
const ids = getResultIds(res);
|
|
79
|
+
return findItemsByIds(
|
|
80
|
+
documents,
|
|
81
|
+
ids,
|
|
82
|
+
this.idKey as Extract<keyof T, string>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Asynchronously search the index for the given term
|
|
88
|
+
*
|
|
89
|
+
* @param documents - The list of documents to search
|
|
90
|
+
* @param searchTerm - The term to search for
|
|
91
|
+
* @returns The list of items that match the search term
|
|
92
|
+
*/
|
|
93
|
+
public async searchAsync(documents: T[], searchTerm: string) {
|
|
94
|
+
return this.index
|
|
95
|
+
.searchAsync(searchTerm)
|
|
96
|
+
.then((res) => {
|
|
97
|
+
return getResultIds(res);
|
|
98
|
+
})
|
|
99
|
+
.then((res) =>
|
|
100
|
+
findItemsByIds(documents, res, this.idKey as Extract<keyof T, string>)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|