@kyco-utils/search 0.0.0-stage → 0.0.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/README.md +77 -2
- package/dist/index.d.mts +127 -0
- package/dist/index.mjs +306 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -4
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kyco-utils/search
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- #region docs -->
|
|
4
|
+
|
|
5
|
+
Dependency-free search that ignores case and accents, with highlighting.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @kyco-utils/search
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Filter a list
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { filterBySearch } from '@kyco-utils/search'
|
|
19
|
+
|
|
20
|
+
const users = [
|
|
21
|
+
{ name: 'Renée Smith', city: 'Paris' },
|
|
22
|
+
{ name: 'John Smith', city: 'London' },
|
|
23
|
+
{ name: 'Jane Doe', city: 'Paris' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
filterBySearch(users, 'smith paris', (user) => [user.name, user.city])
|
|
27
|
+
// [{ name: 'Renée Smith', city: 'Paris' }]
|
|
28
|
+
|
|
29
|
+
filterBySearch(users, 'renee', (user) => [user.name, user.city])
|
|
30
|
+
// [{ name: 'Renée Smith', city: 'Paris' }]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every query word must match one of the texts, in any order.
|
|
34
|
+
|
|
35
|
+
### Match a single item
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { matchesSearch } from '@kyco-utils/search'
|
|
39
|
+
|
|
40
|
+
matchesSearch(['John Doe', 'New York'], 'john') // true
|
|
41
|
+
matchesSearch(['Jane Doe', 'Paris'], 'john') // false
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Highlight matches
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { getHighlightSegments } from '@kyco-utils/search'
|
|
48
|
+
|
|
49
|
+
getHighlightSegments('Café Royal', 'caf')
|
|
50
|
+
// [
|
|
51
|
+
// { text: 'Caf', isMatch: true, isInMatchedWord: true },
|
|
52
|
+
// { text: 'é', isMatch: false, isInMatchedWord: true },
|
|
53
|
+
// { text: ' Royal', isMatch: false, isInMatchedWord: false },
|
|
54
|
+
// ]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Segments are plain text, so the query can't inject markup.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
| Option | Default | Description |
|
|
62
|
+
| --------------------- | ------- | ------------------------------------------------------------------------------ |
|
|
63
|
+
| `ignoreDiacritics` | `true` | `é` matches `e`, `ü` matches `u`. |
|
|
64
|
+
| `matchFrom` | `'any'` | `'any'` matches anywhere; `'wordStart'` only at the start of a word. |
|
|
65
|
+
| `germanNormalization` | `false` | Also match `ä`, `ö`, `ü` with `ae`, `oe`, `ue`, e.g. `mueller` finds `Müller`. |
|
|
66
|
+
|
|
67
|
+
## Matching rules
|
|
68
|
+
|
|
69
|
+
- Case is ignored, and `ß` matches `ss`.
|
|
70
|
+
- The query is split into words on anything that isn't a letter or number.
|
|
71
|
+
|
|
72
|
+
<!-- #endregion docs -->
|
|
73
|
+
|
|
74
|
+
See the [API reference](https://kyco.github.io/kyco-utils/api/@kyco-utils/search) for all exports.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
[MIT](https://github.com/kyco/kyco-utils/blob/main/LICENSE)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** A text to search in. `null` and `undefined` never match. */
|
|
2
|
+
export type SearchText = string | null | undefined;
|
|
3
|
+
/** What to search for. A string is split into words; an array is treated as separate words. */
|
|
4
|
+
export type SearchQuery = string | readonly string[];
|
|
5
|
+
export type SearchOptions = {
|
|
6
|
+
/** Ignore diacritics, e.g. `é` ↔ `e`, `ä` ↔ `a`. Default `true`. */
|
|
7
|
+
ignoreDiacritics?: boolean;
|
|
8
|
+
/** Treat `ä`, `ö`, `ü` as `ae`, `oe`, `ue` (German spelling). Default `false`. */
|
|
9
|
+
germanNormalization?: boolean;
|
|
10
|
+
/** Where a query word may match: anywhere in the text (`'any'`), or only where a word starts (`'wordStart'`). Default `'any'`. */
|
|
11
|
+
matchFrom?: 'any' | 'wordStart';
|
|
12
|
+
};
|
|
13
|
+
/** A range in the original text. `end` is exclusive. */
|
|
14
|
+
export type SearchMatchRange = {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
};
|
|
18
|
+
/** A piece of text from {@link getHighlightSegments} or {@link splitByMatchRanges}. */
|
|
19
|
+
export type HighlightSegment = {
|
|
20
|
+
text: string;
|
|
21
|
+
/** The exact matched letters. */
|
|
22
|
+
isMatch: boolean;
|
|
23
|
+
/** Inside a word that has a match, e.g. to highlight the whole word. */
|
|
24
|
+
isInMatchedWord: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Returns whether every word of the query matches in at least one of the texts, in any order. Case is always ignored.
|
|
28
|
+
* A query without words matches everything.
|
|
29
|
+
*
|
|
30
|
+
* @param texts - The texts to search in, e.g. several fields of one item.
|
|
31
|
+
* @param query - The search query.
|
|
32
|
+
* @param options - See {@link SearchOptions}.
|
|
33
|
+
* @returns `true` if every query word matches.
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { matchesSearch } from '@kyco-utils/search'
|
|
37
|
+
*
|
|
38
|
+
* matchesSearch(['Renée Smith', 'Paris'], 'smith renee') // true
|
|
39
|
+
* matchesSearch(['Invoice'], 'voice', { matchFrom: 'wordStart' }) // false
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare const matchesSearch: (texts: readonly SearchText[], query: SearchQuery, options?: SearchOptions) => boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Returns the items where every word of the query matches in at least one of the item's texts (see
|
|
45
|
+
* {@link matchesSearch}), in their original order. For a query without words, returns `items` itself, not a copy.
|
|
46
|
+
*
|
|
47
|
+
* @param items - The items to filter.
|
|
48
|
+
* @param query - The search query.
|
|
49
|
+
* @param getTexts - Returns the texts to search in for an item.
|
|
50
|
+
* @param options - See {@link SearchOptions}.
|
|
51
|
+
* @returns The matching items.
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import { filterBySearch } from '@kyco-utils/search'
|
|
55
|
+
*
|
|
56
|
+
* const users = [
|
|
57
|
+
* { name: 'Renée Smith', city: 'Paris' },
|
|
58
|
+
* { name: 'John Smith', city: 'London' },
|
|
59
|
+
* { name: 'Jane Doe', city: 'Paris' },
|
|
60
|
+
* ]
|
|
61
|
+
*
|
|
62
|
+
* filterBySearch(users, 'smith paris', (user) => [user.name, user.city])
|
|
63
|
+
* // [{ name: 'Renée Smith', city: 'Paris' }]
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare const filterBySearch: <T>(items: readonly T[], query: SearchQuery, getTexts: (item: T) => readonly SearchText[], options?: SearchOptions) => readonly T[];
|
|
67
|
+
/**
|
|
68
|
+
* Returns the ranges of the original text matched by any query word, sorted and merged, for highlighting. Ranges always
|
|
69
|
+
* cover whole characters, e.g. the whole `ß` when only one `s` matched.
|
|
70
|
+
*
|
|
71
|
+
* @param text - The text to find matches in.
|
|
72
|
+
* @param query - The search query.
|
|
73
|
+
* @param options - See {@link SearchOptions}. Use the same options as for filtering.
|
|
74
|
+
* @returns The matched ranges, `end` exclusive. Empty for a query without words.
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* import { findMatchRanges } from '@kyco-utils/search'
|
|
78
|
+
*
|
|
79
|
+
* findMatchRanges('Café Royal', 'cafe') // [{ start: 0, end: 4 }]
|
|
80
|
+
* findMatchRanges('Renée Smith', 'sm re') // [{ start: 0, end: 2 }, { start: 6, end: 8 }]
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare const findMatchRanges: (text: string, query: SearchQuery, options?: SearchOptions) => SearchMatchRange[];
|
|
84
|
+
/**
|
|
85
|
+
* Splits a text into segments for highlighting: the matched letters (`isMatch`) and the words they're in
|
|
86
|
+
* (`isInMatchedWord`). Words are runs of letters, numbers and marks. The ranges are sorted and merged first, so any
|
|
87
|
+
* ranges work.
|
|
88
|
+
*
|
|
89
|
+
* @param text - The text to split.
|
|
90
|
+
* @param ranges - The ranges to mark, e.g. from {@link findMatchRanges}. For a query, use {@link getHighlightSegments}.
|
|
91
|
+
* @returns The segments, which joined give back `text`. Empty for an empty text.
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* import { findMatchRanges, splitByMatchRanges } from '@kyco-utils/search'
|
|
95
|
+
*
|
|
96
|
+
* splitByMatchRanges('Invoice no', findMatchRanges('Invoice no', 'voi'))
|
|
97
|
+
* // [
|
|
98
|
+
* // { text: 'In', isMatch: false, isInMatchedWord: true },
|
|
99
|
+
* // { text: 'voi', isMatch: true, isInMatchedWord: true },
|
|
100
|
+
* // { text: 'ce', isMatch: false, isInMatchedWord: true },
|
|
101
|
+
* // { text: ' no', isMatch: false, isInMatchedWord: false },
|
|
102
|
+
* // ]
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export declare const splitByMatchRanges: (text: string, ranges: readonly SearchMatchRange[]) => HighlightSegment[];
|
|
106
|
+
/**
|
|
107
|
+
* Splits a text into highlight segments for a query: shorthand for
|
|
108
|
+
* `splitByMatchRanges(text, findMatchRanges(text, query, options))`.
|
|
109
|
+
*
|
|
110
|
+
* @param text - The text to split.
|
|
111
|
+
* @param query - The search query.
|
|
112
|
+
* @param options - See {@link SearchOptions}. Use the same options as for filtering.
|
|
113
|
+
* @returns The segments, which joined give back `text`. Empty for an empty text.
|
|
114
|
+
* @example
|
|
115
|
+
* ```ts
|
|
116
|
+
* import { getHighlightSegments } from '@kyco-utils/search'
|
|
117
|
+
*
|
|
118
|
+
* getHighlightSegments('Café Royal', 'caf')
|
|
119
|
+
* // [
|
|
120
|
+
* // { text: 'Caf', isMatch: true, isInMatchedWord: true },
|
|
121
|
+
* // { text: 'é', isMatch: false, isInMatchedWord: true },
|
|
122
|
+
* // { text: ' Royal', isMatch: false, isInMatchedWord: false },
|
|
123
|
+
* // ]
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export declare const getHighlightSegments: (text: string, query: SearchQuery, options?: SearchOptions) => HighlightSegment[];
|
|
127
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
const NON_WORD_CHARS = /[^\p{L}\p{N}\p{M}]+/u;
|
|
3
|
+
const WORD_CHAR_AT_END = /[\p{L}\p{N}\p{M}]$/u;
|
|
4
|
+
const WORDS = /[\p{L}\p{N}\p{M}]+/gu;
|
|
5
|
+
const UNITS = /\P{M}\p{M}*|\p{M}+/gu;
|
|
6
|
+
const MARKS = /\p{M}/gu;
|
|
7
|
+
const UMLAUTS = /[äöü]/g;
|
|
8
|
+
const SPELL_OUT_TRIGGER = /[äöü]|ae|oe|ue/;
|
|
9
|
+
const SPELLED_OUT = {
|
|
10
|
+
ä: "ae",
|
|
11
|
+
ö: "oe",
|
|
12
|
+
ü: "ue"
|
|
13
|
+
};
|
|
14
|
+
const MAX_TEXT_CACHE_SIZE = 1e5;
|
|
15
|
+
const MAX_UNIT_CACHE_SIZE = 1e4;
|
|
16
|
+
const modeIndex = ({ spellOut, stripMarks }) => (spellOut ? 2 : 0) + (stripMarks ? 1 : 0);
|
|
17
|
+
const textCaches = [
|
|
18
|
+
/* @__PURE__ */ new Map(),
|
|
19
|
+
/* @__PURE__ */ new Map(),
|
|
20
|
+
/* @__PURE__ */ new Map(),
|
|
21
|
+
/* @__PURE__ */ new Map()
|
|
22
|
+
];
|
|
23
|
+
const unitCaches = [
|
|
24
|
+
/* @__PURE__ */ new Map(),
|
|
25
|
+
/* @__PURE__ */ new Map(),
|
|
26
|
+
/* @__PURE__ */ new Map(),
|
|
27
|
+
/* @__PURE__ */ new Map()
|
|
28
|
+
];
|
|
29
|
+
const isAscii = (text) => {
|
|
30
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) > 127) return false;
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
/** Folds one character with its combining marks: NFC, lowercase, `ß` → `ss`, optionally spell out umlauts and strip marks. */
|
|
34
|
+
const foldUnit = (unit, mode) => {
|
|
35
|
+
const cache = unitCaches[modeIndex(mode)];
|
|
36
|
+
const cached = cache.get(unit);
|
|
37
|
+
if (cached !== void 0) return cached;
|
|
38
|
+
let folded = unit.normalize("NFC").toLowerCase().replaceAll("ß", "ss");
|
|
39
|
+
if (mode.spellOut) folded = folded.replace(UMLAUTS, (char) => SPELLED_OUT[char] ?? char);
|
|
40
|
+
if (mode.stripMarks) folded = folded.normalize("NFD").replace(MARKS, "");
|
|
41
|
+
if (cache.size >= MAX_UNIT_CACHE_SIZE) cache.clear();
|
|
42
|
+
cache.set(unit, folded);
|
|
43
|
+
return folded;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Folds a text for matching. `starts[i]` and `ends[i]` are the range in the original text that folded char `i` came
|
|
47
|
+
* from. A character with its combining marks is folded as one unit, so `a` + combining diaeresis folds like `ä`.
|
|
48
|
+
*/
|
|
49
|
+
const fold = (text, mode) => {
|
|
50
|
+
if (isAscii(text)) {
|
|
51
|
+
const starts = Array.from({ length: text.length }, (_, i) => i);
|
|
52
|
+
return {
|
|
53
|
+
folded: text.toLowerCase(),
|
|
54
|
+
starts,
|
|
55
|
+
ends: starts.map((i) => i + 1)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let folded = "";
|
|
59
|
+
const starts = [];
|
|
60
|
+
const ends = [];
|
|
61
|
+
for (const match of text.matchAll(UNITS)) {
|
|
62
|
+
const unit = match[0];
|
|
63
|
+
const foldedUnit = foldUnit(unit, mode);
|
|
64
|
+
folded += foldedUnit;
|
|
65
|
+
for (let i = 0; i < foldedUnit.length; i++) {
|
|
66
|
+
starts.push(match.index);
|
|
67
|
+
ends.push(match.index + unit.length);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
folded,
|
|
72
|
+
starts,
|
|
73
|
+
ends
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/** The folded text only, cached per text and fold mode. */
|
|
77
|
+
const foldCached = (text, mode) => {
|
|
78
|
+
if (isAscii(text)) return text.toLowerCase();
|
|
79
|
+
const cache = textCaches[modeIndex(mode)];
|
|
80
|
+
const cached = cache.get(text);
|
|
81
|
+
if (cached !== void 0) return cached;
|
|
82
|
+
const { folded } = fold(text, mode);
|
|
83
|
+
if (cache.size >= MAX_TEXT_CACHE_SIZE) {
|
|
84
|
+
const oldest = cache.keys().next().value;
|
|
85
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
86
|
+
}
|
|
87
|
+
cache.set(text, folded);
|
|
88
|
+
return folded;
|
|
89
|
+
};
|
|
90
|
+
const getModes = (options) => {
|
|
91
|
+
const stripMarks = options?.ignoreDiacritics ?? true;
|
|
92
|
+
return {
|
|
93
|
+
plain: {
|
|
94
|
+
spellOut: false,
|
|
95
|
+
stripMarks
|
|
96
|
+
},
|
|
97
|
+
spelled: options?.germanNormalization ?? false ? {
|
|
98
|
+
spellOut: true,
|
|
99
|
+
stripMarks
|
|
100
|
+
} : null
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const getQueryWords = (query, options) => {
|
|
104
|
+
const { plain, spelled } = getModes(options);
|
|
105
|
+
return (typeof query === "string" ? query : query.join(" ")).split(NON_WORD_CHARS).filter((word) => word !== "").map((word) => ({
|
|
106
|
+
plain: foldCached(word, plain),
|
|
107
|
+
spelled: spelled !== null && SPELL_OUT_TRIGGER.test(foldCached(word, {
|
|
108
|
+
spellOut: false,
|
|
109
|
+
stripMarks: false
|
|
110
|
+
})) ? foldCached(word, spelled) : null
|
|
111
|
+
}));
|
|
112
|
+
};
|
|
113
|
+
const isWordStartAt = (folded, index) => index === 0 || !WORD_CHAR_AT_END.test(folded.slice(Math.max(0, index - 2), index));
|
|
114
|
+
/** Index of the next occurrence of `needle` at or after `from`, only at word starts if `wordStart`; `-1` if none. */
|
|
115
|
+
const findFrom = (folded, needle, wordStart, from) => {
|
|
116
|
+
let index = folded.indexOf(needle, from);
|
|
117
|
+
while (index !== -1 && wordStart && !isWordStartAt(folded, index)) index = folded.indexOf(needle, index + 1);
|
|
118
|
+
return index;
|
|
119
|
+
};
|
|
120
|
+
const mergeRanges = (ranges, textLength) => {
|
|
121
|
+
const sorted = ranges.map(({ start, end }) => ({
|
|
122
|
+
start: Math.max(0, start),
|
|
123
|
+
end: Math.min(textLength, end)
|
|
124
|
+
})).filter(({ start, end }) => start < end).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
125
|
+
const merged = [];
|
|
126
|
+
for (const range of sorted) {
|
|
127
|
+
const last = merged.at(-1);
|
|
128
|
+
if (last !== void 0 && range.start <= last.end) last.end = Math.max(last.end, range.end);
|
|
129
|
+
else merged.push(range);
|
|
130
|
+
}
|
|
131
|
+
return merged;
|
|
132
|
+
};
|
|
133
|
+
const matchesWords = (texts, words, options) => {
|
|
134
|
+
const { plain, spelled } = getModes(options);
|
|
135
|
+
const wordStart = options?.matchFrom === "wordStart";
|
|
136
|
+
return words.every((word) => texts.some((text) => {
|
|
137
|
+
if (text === null || text === void 0 || text === "") return false;
|
|
138
|
+
if (findFrom(foldCached(text, plain), word.plain, wordStart, 0) !== -1) return true;
|
|
139
|
+
return spelled !== null && word.spelled !== null && findFrom(foldCached(text, spelled), word.spelled, wordStart, 0) !== -1;
|
|
140
|
+
}));
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Returns whether every word of the query matches in at least one of the texts, in any order. Case is always ignored.
|
|
144
|
+
* A query without words matches everything.
|
|
145
|
+
*
|
|
146
|
+
* @param texts - The texts to search in, e.g. several fields of one item.
|
|
147
|
+
* @param query - The search query.
|
|
148
|
+
* @param options - See {@link SearchOptions}.
|
|
149
|
+
* @returns `true` if every query word matches.
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { matchesSearch } from '@kyco-utils/search'
|
|
153
|
+
*
|
|
154
|
+
* matchesSearch(['Renée Smith', 'Paris'], 'smith renee') // true
|
|
155
|
+
* matchesSearch(['Invoice'], 'voice', { matchFrom: 'wordStart' }) // false
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
const matchesSearch = (texts, query, options) => matchesWords(texts, getQueryWords(query, options), options);
|
|
159
|
+
/**
|
|
160
|
+
* Returns the items where every word of the query matches in at least one of the item's texts (see
|
|
161
|
+
* {@link matchesSearch}), in their original order. For a query without words, returns `items` itself, not a copy.
|
|
162
|
+
*
|
|
163
|
+
* @param items - The items to filter.
|
|
164
|
+
* @param query - The search query.
|
|
165
|
+
* @param getTexts - Returns the texts to search in for an item.
|
|
166
|
+
* @param options - See {@link SearchOptions}.
|
|
167
|
+
* @returns The matching items.
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* import { filterBySearch } from '@kyco-utils/search'
|
|
171
|
+
*
|
|
172
|
+
* const users = [
|
|
173
|
+
* { name: 'Renée Smith', city: 'Paris' },
|
|
174
|
+
* { name: 'John Smith', city: 'London' },
|
|
175
|
+
* { name: 'Jane Doe', city: 'Paris' },
|
|
176
|
+
* ]
|
|
177
|
+
*
|
|
178
|
+
* filterBySearch(users, 'smith paris', (user) => [user.name, user.city])
|
|
179
|
+
* // [{ name: 'Renée Smith', city: 'Paris' }]
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
const filterBySearch = (items, query, getTexts, options) => {
|
|
183
|
+
const words = getQueryWords(query, options);
|
|
184
|
+
if (words.length === 0) return items;
|
|
185
|
+
return items.filter((item) => matchesWords(getTexts(item), words, options));
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Returns the ranges of the original text matched by any query word, sorted and merged, for highlighting. Ranges always
|
|
189
|
+
* cover whole characters, e.g. the whole `ß` when only one `s` matched.
|
|
190
|
+
*
|
|
191
|
+
* @param text - The text to find matches in.
|
|
192
|
+
* @param query - The search query.
|
|
193
|
+
* @param options - See {@link SearchOptions}. Use the same options as for filtering.
|
|
194
|
+
* @returns The matched ranges, `end` exclusive. Empty for a query without words.
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* import { findMatchRanges } from '@kyco-utils/search'
|
|
198
|
+
*
|
|
199
|
+
* findMatchRanges('Café Royal', 'cafe') // [{ start: 0, end: 4 }]
|
|
200
|
+
* findMatchRanges('Renée Smith', 'sm re') // [{ start: 0, end: 2 }, { start: 6, end: 8 }]
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
const findMatchRanges = (text, query, options) => {
|
|
204
|
+
const words = getQueryWords(query, options);
|
|
205
|
+
if (words.length === 0 || text === "") return [];
|
|
206
|
+
const { plain, spelled } = getModes(options);
|
|
207
|
+
const wordStart = options?.matchFrom === "wordStart";
|
|
208
|
+
const ranges = [];
|
|
209
|
+
let plainFolded;
|
|
210
|
+
let spelledFolded;
|
|
211
|
+
const collect = ({ folded, starts, ends }, needle) => {
|
|
212
|
+
if (needle === "") return;
|
|
213
|
+
for (let index = findFrom(folded, needle, wordStart, 0); index !== -1;) {
|
|
214
|
+
ranges.push({
|
|
215
|
+
start: starts[index] ?? 0,
|
|
216
|
+
end: ends[index + needle.length - 1] ?? text.length
|
|
217
|
+
});
|
|
218
|
+
index = findFrom(folded, needle, wordStart, index + 1);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
for (const word of words) {
|
|
222
|
+
plainFolded ??= fold(text, plain);
|
|
223
|
+
collect(plainFolded, word.plain);
|
|
224
|
+
if (spelled !== null && word.spelled !== null) {
|
|
225
|
+
spelledFolded ??= fold(text, spelled);
|
|
226
|
+
collect(spelledFolded, word.spelled);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return mergeRanges(ranges, text.length);
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Splits a text into segments for highlighting: the matched letters (`isMatch`) and the words they're in
|
|
233
|
+
* (`isInMatchedWord`). Words are runs of letters, numbers and marks. The ranges are sorted and merged first, so any
|
|
234
|
+
* ranges work.
|
|
235
|
+
*
|
|
236
|
+
* @param text - The text to split.
|
|
237
|
+
* @param ranges - The ranges to mark, e.g. from {@link findMatchRanges}. For a query, use {@link getHighlightSegments}.
|
|
238
|
+
* @returns The segments, which joined give back `text`. Empty for an empty text.
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* import { findMatchRanges, splitByMatchRanges } from '@kyco-utils/search'
|
|
242
|
+
*
|
|
243
|
+
* splitByMatchRanges('Invoice no', findMatchRanges('Invoice no', 'voi'))
|
|
244
|
+
* // [
|
|
245
|
+
* // { text: 'In', isMatch: false, isInMatchedWord: true },
|
|
246
|
+
* // { text: 'voi', isMatch: true, isInMatchedWord: true },
|
|
247
|
+
* // { text: 'ce', isMatch: false, isInMatchedWord: true },
|
|
248
|
+
* // { text: ' no', isMatch: false, isInMatchedWord: false },
|
|
249
|
+
* // ]
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
const splitByMatchRanges = (text, ranges) => {
|
|
253
|
+
if (text === "") return [];
|
|
254
|
+
const matches = mergeRanges(ranges, text.length);
|
|
255
|
+
const overlaps = (a, b) => a.start < b.end && b.start < a.end;
|
|
256
|
+
const matchedWords = Array.from(text.matchAll(WORDS), (match) => ({
|
|
257
|
+
start: match.index,
|
|
258
|
+
end: match.index + match[0].length
|
|
259
|
+
})).filter((word) => matches.some((match) => overlaps(word, match)));
|
|
260
|
+
const boundaries = [.../* @__PURE__ */ new Set([
|
|
261
|
+
0,
|
|
262
|
+
text.length,
|
|
263
|
+
...[...matches, ...matchedWords].flatMap(({ start, end }) => [start, end])
|
|
264
|
+
])].sort((a, b) => a - b);
|
|
265
|
+
const segments = [];
|
|
266
|
+
for (let i = 0; i < boundaries.length - 1; i++) {
|
|
267
|
+
const piece = {
|
|
268
|
+
start: boundaries[i] ?? 0,
|
|
269
|
+
end: boundaries[i + 1] ?? text.length
|
|
270
|
+
};
|
|
271
|
+
const isMatch = matches.some((match) => overlaps(piece, match));
|
|
272
|
+
const isInMatchedWord = matchedWords.some((word) => overlaps(piece, word));
|
|
273
|
+
const last = segments.at(-1);
|
|
274
|
+
if (last !== void 0 && last.isMatch === isMatch && last.isInMatchedWord === isInMatchedWord) last.text += text.slice(piece.start, piece.end);
|
|
275
|
+
else segments.push({
|
|
276
|
+
text: text.slice(piece.start, piece.end),
|
|
277
|
+
isMatch,
|
|
278
|
+
isInMatchedWord
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return segments;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Splits a text into highlight segments for a query: shorthand for
|
|
285
|
+
* `splitByMatchRanges(text, findMatchRanges(text, query, options))`.
|
|
286
|
+
*
|
|
287
|
+
* @param text - The text to split.
|
|
288
|
+
* @param query - The search query.
|
|
289
|
+
* @param options - See {@link SearchOptions}. Use the same options as for filtering.
|
|
290
|
+
* @returns The segments, which joined give back `text`. Empty for an empty text.
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* import { getHighlightSegments } from '@kyco-utils/search'
|
|
294
|
+
*
|
|
295
|
+
* getHighlightSegments('Café Royal', 'caf')
|
|
296
|
+
* // [
|
|
297
|
+
* // { text: 'Caf', isMatch: true, isInMatchedWord: true },
|
|
298
|
+
* // { text: 'é', isMatch: false, isInMatchedWord: true },
|
|
299
|
+
* // { text: ' Royal', isMatch: false, isInMatchedWord: false },
|
|
300
|
+
* // ]
|
|
301
|
+
* ```
|
|
302
|
+
*/
|
|
303
|
+
const getHighlightSegments = (text, query, options) => splitByMatchRanges(text, findMatchRanges(text, query, options));
|
|
304
|
+
export { filterBySearch, findMatchRanges, getHighlightSegments, matchesSearch, splitByMatchRanges };
|
|
305
|
+
|
|
306
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/** A text to search in. `null` and `undefined` never match. */\nexport type SearchText = string | null | undefined\n\n/** What to search for. A string is split into words; an array is treated as separate words. */\nexport type SearchQuery = string | readonly string[]\n\nexport type SearchOptions = {\n /** Ignore diacritics, e.g. `é` ↔ `e`, `ä` ↔ `a`. Default `true`. */\n ignoreDiacritics?: boolean\n /** Treat `ä`, `ö`, `ü` as `ae`, `oe`, `ue` (German spelling). Default `false`. */\n germanNormalization?: boolean\n /** Where a query word may match: anywhere in the text (`'any'`), or only where a word starts (`'wordStart'`). Default `'any'`. */\n matchFrom?: 'any' | 'wordStart'\n}\n\n/** A range in the original text. `end` is exclusive. */\nexport type SearchMatchRange = { start: number; end: number }\n\n/** A piece of text from {@link getHighlightSegments} or {@link splitByMatchRanges}. */\nexport type HighlightSegment = {\n text: string\n /** The exact matched letters. */\n isMatch: boolean\n /** Inside a word that has a match, e.g. to highlight the whole word. */\n isInMatchedWord: boolean\n}\n\ntype FoldMode = { spellOut: boolean; stripMarks: boolean }\ntype Folded = { folded: string; starts: number[]; ends: number[] }\ntype QueryWord = { plain: string; spelled: string | null }\n\nconst NON_WORD_CHARS = /[^\\p{L}\\p{N}\\p{M}]+/u\nconst WORD_CHAR_AT_END = /[\\p{L}\\p{N}\\p{M}]$/u\nconst WORDS = /[\\p{L}\\p{N}\\p{M}]+/gu\nconst UNITS = /\\P{M}\\p{M}*|\\p{M}+/gu\nconst MARKS = /\\p{M}/gu\nconst UMLAUTS = /[äöü]/g\nconst SPELL_OUT_TRIGGER = /[äöü]|ae|oe|ue/\nconst SPELLED_OUT: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue' }\n\nconst MAX_TEXT_CACHE_SIZE = 100_000\nconst MAX_UNIT_CACHE_SIZE = 10_000\n\nconst modeIndex = ({ spellOut, stripMarks }: FoldMode): number => (spellOut ? 2 : 0) + (stripMarks ? 1 : 0)\nconst textCaches: Map<string, string>[] = [new Map(), new Map(), new Map(), new Map()]\nconst unitCaches: Map<string, string>[] = [new Map(), new Map(), new Map(), new Map()]\n\nconst isAscii = (text: string): boolean => {\n for (let i = 0; i < text.length; i++) {\n if (text.charCodeAt(i) > 127) return false\n }\n return true\n}\n\n/** Folds one character with its combining marks: NFC, lowercase, `ß` → `ss`, optionally spell out umlauts and strip marks. */\nconst foldUnit = (unit: string, mode: FoldMode): string => {\n const cache = unitCaches[modeIndex(mode)] as Map<string, string>\n const cached = cache.get(unit)\n if (cached !== undefined) return cached\n\n let folded = unit.normalize('NFC').toLowerCase().replaceAll('ß', 'ss')\n if (mode.spellOut) folded = folded.replace(UMLAUTS, (char) => SPELLED_OUT[char] ?? char)\n if (mode.stripMarks) folded = folded.normalize('NFD').replace(MARKS, '')\n\n if (cache.size >= MAX_UNIT_CACHE_SIZE) cache.clear()\n cache.set(unit, folded)\n return folded\n}\n\n/**\n * Folds a text for matching. `starts[i]` and `ends[i]` are the range in the original text that folded char `i` came\n * from. A character with its combining marks is folded as one unit, so `a` + combining diaeresis folds like `ä`.\n */\nconst fold = (text: string, mode: FoldMode): Folded => {\n if (isAscii(text)) {\n const starts = Array.from({ length: text.length }, (_, i) => i)\n return { folded: text.toLowerCase(), starts, ends: starts.map((i) => i + 1) }\n }\n\n let folded = ''\n const starts: number[] = []\n const ends: number[] = []\n for (const match of text.matchAll(UNITS)) {\n const unit = match[0]\n const foldedUnit = foldUnit(unit, mode)\n folded += foldedUnit\n for (let i = 0; i < foldedUnit.length; i++) {\n starts.push(match.index)\n ends.push(match.index + unit.length)\n }\n }\n return { folded, starts, ends }\n}\n\n/** The folded text only, cached per text and fold mode. */\nconst foldCached = (text: string, mode: FoldMode): string => {\n if (isAscii(text)) return text.toLowerCase()\n\n const cache = textCaches[modeIndex(mode)] as Map<string, string>\n const cached = cache.get(text)\n if (cached !== undefined) return cached\n\n const { folded } = fold(text, mode)\n if (cache.size >= MAX_TEXT_CACHE_SIZE) {\n const oldest = cache.keys().next().value\n if (oldest !== undefined) cache.delete(oldest)\n }\n cache.set(text, folded)\n return folded\n}\n\nconst getModes = (options: SearchOptions | undefined): { plain: FoldMode; spelled: FoldMode | null } => {\n const stripMarks = options?.ignoreDiacritics ?? true\n return {\n plain: { spellOut: false, stripMarks },\n spelled: (options?.germanNormalization ?? false) ? { spellOut: true, stripMarks } : null,\n }\n}\n\nconst getQueryWords = (query: SearchQuery, options: SearchOptions | undefined): QueryWord[] => {\n const { plain, spelled } = getModes(options)\n const joined = typeof query === 'string' ? query : query.join(' ')\n return joined\n .split(NON_WORD_CHARS)\n .filter((word) => word !== '')\n .map((word) => ({\n plain: foldCached(word, plain),\n // Spelling out can only add a meaningful match for words with an umlaut or its spelled-out form.\n spelled:\n spelled !== null && SPELL_OUT_TRIGGER.test(foldCached(word, { spellOut: false, stripMarks: false }))\n ? foldCached(word, spelled)\n : null,\n }))\n}\n\nconst isWordStartAt = (folded: string, index: number): boolean =>\n index === 0 || !WORD_CHAR_AT_END.test(folded.slice(Math.max(0, index - 2), index))\n\n/** Index of the next occurrence of `needle` at or after `from`, only at word starts if `wordStart`; `-1` if none. */\nconst findFrom = (folded: string, needle: string, wordStart: boolean, from: number): number => {\n let index = folded.indexOf(needle, from)\n while (index !== -1 && wordStart && !isWordStartAt(folded, index)) {\n index = folded.indexOf(needle, index + 1)\n }\n return index\n}\n\nconst mergeRanges = (ranges: readonly SearchMatchRange[], textLength: number): SearchMatchRange[] => {\n const sorted = ranges\n .map(({ start, end }) => ({ start: Math.max(0, start), end: Math.min(textLength, end) }))\n .filter(({ start, end }) => start < end)\n .sort((a, b) => a.start - b.start || a.end - b.end)\n\n const merged: SearchMatchRange[] = []\n for (const range of sorted) {\n const last = merged.at(-1)\n if (last !== undefined && range.start <= last.end) {\n last.end = Math.max(last.end, range.end)\n } else {\n merged.push(range)\n }\n }\n return merged\n}\n\nconst matchesWords = (texts: readonly SearchText[], words: readonly QueryWord[], options?: SearchOptions): boolean => {\n const { plain, spelled } = getModes(options)\n const wordStart = options?.matchFrom === 'wordStart'\n\n return words.every((word) =>\n texts.some((text) => {\n if (text === null || text === undefined || text === '') return false\n if (findFrom(foldCached(text, plain), word.plain, wordStart, 0) !== -1) return true\n return (\n spelled !== null &&\n word.spelled !== null &&\n findFrom(foldCached(text, spelled), word.spelled, wordStart, 0) !== -1\n )\n }),\n )\n}\n\n/**\n * Returns whether every word of the query matches in at least one of the texts, in any order. Case is always ignored.\n * A query without words matches everything.\n *\n * @param texts - The texts to search in, e.g. several fields of one item.\n * @param query - The search query.\n * @param options - See {@link SearchOptions}.\n * @returns `true` if every query word matches.\n * @example\n * ```ts\n * import { matchesSearch } from '@kyco-utils/search'\n *\n * matchesSearch(['Renée Smith', 'Paris'], 'smith renee') // true\n * matchesSearch(['Invoice'], 'voice', { matchFrom: 'wordStart' }) // false\n * ```\n */\nexport const matchesSearch = (texts: readonly SearchText[], query: SearchQuery, options?: SearchOptions): boolean =>\n matchesWords(texts, getQueryWords(query, options), options)\n\n/**\n * Returns the items where every word of the query matches in at least one of the item's texts (see\n * {@link matchesSearch}), in their original order. For a query without words, returns `items` itself, not a copy.\n *\n * @param items - The items to filter.\n * @param query - The search query.\n * @param getTexts - Returns the texts to search in for an item.\n * @param options - See {@link SearchOptions}.\n * @returns The matching items.\n * @example\n * ```ts\n * import { filterBySearch } from '@kyco-utils/search'\n *\n * const users = [\n * { name: 'Renée Smith', city: 'Paris' },\n * { name: 'John Smith', city: 'London' },\n * { name: 'Jane Doe', city: 'Paris' },\n * ]\n *\n * filterBySearch(users, 'smith paris', (user) => [user.name, user.city])\n * // [{ name: 'Renée Smith', city: 'Paris' }]\n * ```\n */\nexport const filterBySearch = <T>(\n items: readonly T[],\n query: SearchQuery,\n getTexts: (item: T) => readonly SearchText[],\n options?: SearchOptions,\n): readonly T[] => {\n const words = getQueryWords(query, options)\n if (words.length === 0) return items\n return items.filter((item) => matchesWords(getTexts(item), words, options))\n}\n\n/**\n * Returns the ranges of the original text matched by any query word, sorted and merged, for highlighting. Ranges always\n * cover whole characters, e.g. the whole `ß` when only one `s` matched.\n *\n * @param text - The text to find matches in.\n * @param query - The search query.\n * @param options - See {@link SearchOptions}. Use the same options as for filtering.\n * @returns The matched ranges, `end` exclusive. Empty for a query without words.\n * @example\n * ```ts\n * import { findMatchRanges } from '@kyco-utils/search'\n *\n * findMatchRanges('Café Royal', 'cafe') // [{ start: 0, end: 4 }]\n * findMatchRanges('Renée Smith', 'sm re') // [{ start: 0, end: 2 }, { start: 6, end: 8 }]\n * ```\n */\nexport const findMatchRanges = (text: string, query: SearchQuery, options?: SearchOptions): SearchMatchRange[] => {\n const words = getQueryWords(query, options)\n if (words.length === 0 || text === '') return []\n\n const { plain, spelled } = getModes(options)\n const wordStart = options?.matchFrom === 'wordStart'\n const ranges: SearchMatchRange[] = []\n let plainFolded: Folded | undefined\n let spelledFolded: Folded | undefined\n\n const collect = ({ folded, starts, ends }: Folded, needle: string) => {\n if (needle === '') return\n for (let index = findFrom(folded, needle, wordStart, 0); index !== -1; ) {\n ranges.push({ start: starts[index] ?? 0, end: ends[index + needle.length - 1] ?? text.length })\n index = findFrom(folded, needle, wordStart, index + 1)\n }\n }\n\n for (const word of words) {\n plainFolded ??= fold(text, plain)\n collect(plainFolded, word.plain)\n if (spelled !== null && word.spelled !== null) {\n spelledFolded ??= fold(text, spelled)\n collect(spelledFolded, word.spelled)\n }\n }\n\n return mergeRanges(ranges, text.length)\n}\n\n/**\n * Splits a text into segments for highlighting: the matched letters (`isMatch`) and the words they're in\n * (`isInMatchedWord`). Words are runs of letters, numbers and marks. The ranges are sorted and merged first, so any\n * ranges work.\n *\n * @param text - The text to split.\n * @param ranges - The ranges to mark, e.g. from {@link findMatchRanges}. For a query, use {@link getHighlightSegments}.\n * @returns The segments, which joined give back `text`. Empty for an empty text.\n * @example\n * ```ts\n * import { findMatchRanges, splitByMatchRanges } from '@kyco-utils/search'\n *\n * splitByMatchRanges('Invoice no', findMatchRanges('Invoice no', 'voi'))\n * // [\n * // { text: 'In', isMatch: false, isInMatchedWord: true },\n * // { text: 'voi', isMatch: true, isInMatchedWord: true },\n * // { text: 'ce', isMatch: false, isInMatchedWord: true },\n * // { text: ' no', isMatch: false, isInMatchedWord: false },\n * // ]\n * ```\n */\nexport const splitByMatchRanges = (text: string, ranges: readonly SearchMatchRange[]): HighlightSegment[] => {\n if (text === '') return []\n\n const matches = mergeRanges(ranges, text.length)\n const overlaps = (a: SearchMatchRange, b: SearchMatchRange) => a.start < b.end && b.start < a.end\n const matchedWords = Array.from(text.matchAll(WORDS), (match) => ({\n start: match.index,\n end: match.index + match[0].length,\n })).filter((word) => matches.some((match) => overlaps(word, match)))\n\n const boundaries = [\n ...new Set([0, text.length, ...[...matches, ...matchedWords].flatMap(({ start, end }) => [start, end])]),\n ].sort((a, b) => a - b)\n\n const segments: HighlightSegment[] = []\n for (let i = 0; i < boundaries.length - 1; i++) {\n const piece = { start: boundaries[i] ?? 0, end: boundaries[i + 1] ?? text.length }\n const isMatch = matches.some((match) => overlaps(piece, match))\n const isInMatchedWord = matchedWords.some((word) => overlaps(piece, word))\n const last = segments.at(-1)\n if (last !== undefined && last.isMatch === isMatch && last.isInMatchedWord === isInMatchedWord) {\n last.text += text.slice(piece.start, piece.end)\n } else {\n segments.push({ text: text.slice(piece.start, piece.end), isMatch, isInMatchedWord })\n }\n }\n return segments\n}\n\n/**\n * Splits a text into highlight segments for a query: shorthand for\n * `splitByMatchRanges(text, findMatchRanges(text, query, options))`.\n *\n * @param text - The text to split.\n * @param query - The search query.\n * @param options - See {@link SearchOptions}. Use the same options as for filtering.\n * @returns The segments, which joined give back `text`. Empty for an empty text.\n * @example\n * ```ts\n * import { getHighlightSegments } from '@kyco-utils/search'\n *\n * getHighlightSegments('Café Royal', 'caf')\n * // [\n * // { text: 'Caf', isMatch: true, isInMatchedWord: true },\n * // { text: 'é', isMatch: false, isInMatchedWord: true },\n * // { text: ' Royal', isMatch: false, isInMatchedWord: false },\n * // ]\n * ```\n */\nexport const getHighlightSegments = (text: string, query: SearchQuery, options?: SearchOptions): HighlightSegment[] =>\n splitByMatchRanges(text, findMatchRanges(text, query, options))\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyco-utils/search",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Dependency-free word search with diacritic folding and highlight ranges.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kyco/kyco-utils.git",
|
|
11
|
+
"directory": "packages/search"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/kyco/kyco-utils/tree/main/packages/search",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"@kyco-utils/source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.mts",
|
|
21
|
+
"default": "./dist/index.mjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "vp pack",
|
|
26
|
+
"dev": "vp pack --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"provenance": true
|
|
32
|
+
}
|
|
33
|
+
}
|