From 95183edffd52009dc0553f6c9605b275e0905d1c Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 12 Mar 2022 14:36:00 -0500 Subject: [PATCH] Improve text extraction logic for searching --- .../helpers/extractSearchFilters.spec.ts | 45 +++++++++++++++++++ .../scripts/helpers/extractSearchFilters.ts | 40 +++++++++++++++++ .../helpers/splitStringWhitespace.spec.ts | 16 +++++++ .../scripts/helpers/splitStringWhitespace.ts | 27 +++++++++++ 4 files changed, 128 insertions(+) create mode 100644 resources/scripts/helpers/extractSearchFilters.spec.ts create mode 100644 resources/scripts/helpers/extractSearchFilters.ts create mode 100644 resources/scripts/helpers/splitStringWhitespace.spec.ts create mode 100644 resources/scripts/helpers/splitStringWhitespace.ts diff --git a/resources/scripts/helpers/extractSearchFilters.spec.ts b/resources/scripts/helpers/extractSearchFilters.spec.ts new file mode 100644 index 000000000..ea36315fb --- /dev/null +++ b/resources/scripts/helpers/extractSearchFilters.spec.ts @@ -0,0 +1,45 @@ +import extractSearchFilters from '@/helpers/extractSearchFilters'; + +type TestCase = [ string, 0 | Record ]; + +describe('@/helpers/extractSearchFilters.ts', function () { + const _DEFAULT = 0x00; + const cases: TestCase[] = [ + [ '', {} ], + [ 'hello world', _DEFAULT ], + [ 'bar:xyz foo:abc', { bar: [ 'xyz' ], foo: [ 'abc' ] } ], + [ 'hello foo:abc', { foo: [ 'abc' ] } ], + [ 'hello foo:abc world another bar:xyz hodor', { foo: [ 'abc' ], bar: [ 'xyz' ] } ], + [ 'foo:1 foo:2 foo: 3 foo:4', { foo: [ '1', '2', '4' ] } ], + [ ' foo:123 foo:bar:123 foo: foo:string', { foo: [ '123', 'bar:123', 'string' ] } ], + [ 'foo:1 bar:2 baz:3', { foo: [ '1' ], bar: [ '2' ] } ], + [ 'hello "world this" is quoted', _DEFAULT ], + [ 'hello "world foo:123 is" quoted', _DEFAULT ], + [ 'hello foo:"this is quoted" bar:"this \\"is deeply\\" quoted" world foo:another', { + foo: [ 'this is quoted', 'another' ], + bar: [ 'this "is deeply" quoted' ], + } ], + ]; + + it.each(cases)('should return expected filters: [%s]', function (input, output) { + expect(extractSearchFilters(input, [ 'foo', 'bar' ])).toStrictEqual({ + filters: output === _DEFAULT ? { + '*': [ input ], + } : output, + }); + }); + + it('should allow modification of the default parameter', function () { + expect(extractSearchFilters('hello world', [ 'foo' ], 'default_param')).toStrictEqual({ + filters: { + default_param: [ 'hello world' ], + }, + }); + + expect(extractSearchFilters('foo:123 bar', [ 'foo' ], 'default_param')).toStrictEqual({ + filters: { + foo: [ '123' ], + }, + }); + }); +}); diff --git a/resources/scripts/helpers/extractSearchFilters.ts b/resources/scripts/helpers/extractSearchFilters.ts new file mode 100644 index 000000000..1497050b8 --- /dev/null +++ b/resources/scripts/helpers/extractSearchFilters.ts @@ -0,0 +1,40 @@ +import { QueryBuilderParams } from '@/api/http'; +import splitStringWhitespace from '@/helpers/splitStringWhitespace'; + +const extractSearchFilters = ( + str: string, + params: T[], + defaultFilter: D = '*' as D, +): QueryBuilderParams | QueryBuilderParams => { + const filters: Map = new Map(); + + if (str.trim().length === 0) { + return { filters: {} }; + } + + for (const segment of splitStringWhitespace(str)) { + const parts = segment.split(':'); + const filter = parts[0] as T; + const value = parts.slice(1).join(':'); + // @ts-ignore + if (!filter || !value || !params.includes(filter)) { + continue; + } + + filters.set(filter, [ ...(filters.get(filter) || []), value ]); + } + + if (filters.size === 0) { + return { + filters: { + [defaultFilter]: [ str ] as Readonly, + } as unknown as QueryBuilderParams['filters'], + }; + } + + return { + filters: Object.fromEntries(filters) as unknown as QueryBuilderParams['filters'], + }; +}; + +export default extractSearchFilters; diff --git a/resources/scripts/helpers/splitStringWhitespace.spec.ts b/resources/scripts/helpers/splitStringWhitespace.spec.ts new file mode 100644 index 000000000..f14d54675 --- /dev/null +++ b/resources/scripts/helpers/splitStringWhitespace.spec.ts @@ -0,0 +1,16 @@ +import splitStringWhitespace from '@/helpers/splitStringWhitespace'; + +describe('@/helpers/splitStringWhitespace.ts', function () { + it.each([ + [ '', [] ], + [ 'hello world', [ 'hello', 'world' ] ], + [ ' hello world ', [ 'hello', 'world' ] ], + [ 'hello123 world 123 $$ s ', [ 'hello123', 'world', '123', '$$', 's' ] ], + [ 'hello world! how are you?', [ 'hello', 'world!', 'how', 'are', 'you?' ] ], + [ 'hello "foo bar baz" world', [ 'hello', 'foo bar baz', 'world' ] ], + [ 'hello "foo \\"bar bar \\" baz" world', [ 'hello', 'foo "bar bar " baz', 'world' ] ], + [ 'hello "foo "bar baz" baz" world', [ 'hello', 'foo bar', 'baz baz', 'world' ] ], + ])('should handle string: %s', function (input, output) { + expect(splitStringWhitespace(input)).toStrictEqual(output); + }); +}); diff --git a/resources/scripts/helpers/splitStringWhitespace.ts b/resources/scripts/helpers/splitStringWhitespace.ts new file mode 100644 index 000000000..5b72160ee --- /dev/null +++ b/resources/scripts/helpers/splitStringWhitespace.ts @@ -0,0 +1,27 @@ +/** + * Takes a string and splits it into an array by whitespace, ignoring any + * text that is wrapped in quotes. You must escape quotes within a quoted + * string, otherwise it will just split on those. + * + * Derived from https://stackoverflow.com/a/46946420 + */ +export default (str: string): string[] => { + let quoted = false; + const parts = [ '' ] as string[]; + + for (const char of (str.trim().match(/\\?.|^$/g) || [])) { + if (char === '"') { + quoted = !quoted; + } else if (!quoted && char === ' ') { + parts.push(''); + } else { + parts[Math.max(parts.length - 1, 0)] += char.replace(/\\(.)/, '$1'); + } + } + + if (parts.length === 1 && parts[0] === '') { + return []; + } + + return parts; +};