333 lines
9.2 KiB
TypeScript
333 lines
9.2 KiB
TypeScript
/*!
|
|
* search-query-parser.js
|
|
* Original: https://github.com/nepsilon/search-query-parser
|
|
* Modified by Simon Cambier
|
|
* Copyright(c) 2014-2019
|
|
* MIT Licensed
|
|
*/
|
|
|
|
interface SearchParserOptions {
|
|
offsets?: boolean
|
|
tokenize: true
|
|
keywords?: string[]
|
|
ranges?: string[]
|
|
alwaysArray?: boolean
|
|
}
|
|
|
|
interface ISearchParserDictionary {
|
|
[key: string]: any
|
|
}
|
|
|
|
type SearchParserKeyWordOffset = {
|
|
keyword: string
|
|
value?: string
|
|
}
|
|
|
|
type SearchParserTextOffset = {
|
|
text: string
|
|
}
|
|
|
|
type SearchParserOffset = (
|
|
| SearchParserKeyWordOffset
|
|
| SearchParserTextOffset
|
|
) & {
|
|
offsetStart: number
|
|
offsetEnd: number
|
|
}
|
|
|
|
interface SearchParserResult extends ISearchParserDictionary {
|
|
text: string[]
|
|
offsets: SearchParserOffset[]
|
|
exclude: { text: string[] }
|
|
}
|
|
|
|
export function parseQuery(
|
|
string: string,
|
|
options: SearchParserOptions,
|
|
): SearchParserResult {
|
|
// Set a default options object when none is provided
|
|
if (!options) {
|
|
options = { offsets: true, tokenize: true }
|
|
}
|
|
else {
|
|
// If options offsets was't passed, set it to true
|
|
options.offsets =
|
|
typeof options.offsets === 'undefined' ? true : options.offsets
|
|
}
|
|
|
|
if (!string) {
|
|
string = ''
|
|
}
|
|
|
|
// Our object to store the query object
|
|
const query: SearchParserResult = {
|
|
text: [],
|
|
offsets: [],
|
|
exclude: { text: [] },
|
|
}
|
|
// When offsets is true, create their array
|
|
if (options.offsets) {
|
|
query.offsets = []
|
|
}
|
|
const exclusion: ISearchParserDictionary & { text: string[] } = { text: [] }
|
|
const terms = []
|
|
// Get a list of search terms respecting single and double quotes
|
|
const regex =
|
|
/(\S+:'(?:[^'\\]|\\.)*')|(\S+:"(?:[^"\\]|\\.)*")|(-?"(?:[^"\\]|\\.)*")|(-?'(?:[^'\\]|\\.)*')|\S+|\S+:\S+/g
|
|
let match
|
|
let count = 0 // TODO: FIXME: this is a hack to avoid infinite loops
|
|
while ((match = regex.exec(string)) !== null) {
|
|
if (++count > 100) break
|
|
let term = match[0]
|
|
const sepIndex = term.indexOf(':')
|
|
|
|
// Terms that contain a `:`
|
|
if (sepIndex !== -1) {
|
|
const key = term.slice(0, sepIndex)
|
|
let val = term.slice(sepIndex + 1)
|
|
|
|
// Strip backslashes respecting escapes
|
|
val = (val + '').replace(/\\(.?)/g, function (s, n1) {
|
|
switch (n1) {
|
|
case '\\':
|
|
return '\\'
|
|
case '0':
|
|
return '\u0000'
|
|
case '':
|
|
return ''
|
|
default:
|
|
return n1
|
|
}
|
|
})
|
|
terms.push({
|
|
keyword: key,
|
|
value: val,
|
|
offsetStart: match.index,
|
|
offsetEnd: match.index + term.length,
|
|
})
|
|
}
|
|
|
|
// Other terms
|
|
else {
|
|
let isExcludedTerm = false
|
|
if (term[0] === '-') {
|
|
isExcludedTerm = true
|
|
term = term.slice(1)
|
|
}
|
|
|
|
// Strip backslashes respecting escapes
|
|
term = (term + '').replace(/\\(.?)/g, function (s, n1) {
|
|
switch (n1) {
|
|
case '\\':
|
|
return '\\'
|
|
case '0':
|
|
return '\u0000'
|
|
case '':
|
|
return ''
|
|
default:
|
|
return n1
|
|
}
|
|
})
|
|
|
|
if (isExcludedTerm) {
|
|
exclusion.text.push(term)
|
|
}
|
|
else {
|
|
terms.push({
|
|
text: term,
|
|
offsetStart: match.index,
|
|
offsetEnd: match.index + term.length,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// Reverse to ensure proper order when pop()'ing.
|
|
terms.reverse()
|
|
// For each search term
|
|
let term
|
|
while ((term = terms.pop())) {
|
|
// When just a simple term
|
|
if (term.text) {
|
|
// We add it as pure text
|
|
query.text.push(term.text)
|
|
// When offsets is true, push a new offset
|
|
if (options.offsets) {
|
|
query.offsets.push(term)
|
|
}
|
|
}
|
|
// We got an advanced search syntax
|
|
else if (term.keyword) {
|
|
let key = term.keyword
|
|
// Check if the key is a registered keyword
|
|
options.keywords = options.keywords || []
|
|
let isKeyword = false
|
|
let isExclusion = false
|
|
if (!/^-/.test(key)) {
|
|
isKeyword = !(options.keywords.indexOf(key) === -1)
|
|
}
|
|
else if (key[0] === '-') {
|
|
const _key = key.slice(1)
|
|
isKeyword = !(options.keywords.indexOf(_key) === -1)
|
|
if (isKeyword) {
|
|
key = _key
|
|
isExclusion = true
|
|
}
|
|
}
|
|
|
|
// Check if the key is a registered range
|
|
options.ranges = options.ranges || []
|
|
const isRange = !(options.ranges.indexOf(key) === -1)
|
|
// When the key matches a keyword
|
|
if (isKeyword) {
|
|
// When offsets is true, push a new offset
|
|
if (options.offsets) {
|
|
query.offsets.push({
|
|
keyword: key,
|
|
value: term.value,
|
|
offsetStart: isExclusion ? term.offsetStart + 1 : term.offsetStart,
|
|
offsetEnd: term.offsetEnd,
|
|
})
|
|
}
|
|
|
|
const value = term.value
|
|
// When value is a thing
|
|
if (value.length) {
|
|
// Get an array of values when several are there
|
|
const values = value.split(',')
|
|
if (isExclusion) {
|
|
if (exclusion[key]) {
|
|
// ...many times...
|
|
if (exclusion[key] instanceof Array) {
|
|
// ...and got several values this time...
|
|
if (values.length > 1) {
|
|
// ... concatenate both arrays.
|
|
exclusion[key] = exclusion[key].concat(values)
|
|
}
|
|
else {
|
|
// ... append the current single value.
|
|
exclusion[key].push(value)
|
|
}
|
|
}
|
|
// We saw that keyword only once before
|
|
else {
|
|
// Put both the current value and the new
|
|
// value in an array
|
|
exclusion[key] = [exclusion[key]]
|
|
exclusion[key].push(value)
|
|
}
|
|
}
|
|
// First time we see that keyword
|
|
else {
|
|
// ...and got several values this time...
|
|
if (values.length > 1) {
|
|
// ...add all values seen.
|
|
exclusion[key] = values
|
|
}
|
|
// Got only a single value this time
|
|
else {
|
|
// Record its value as a string
|
|
if (options.alwaysArray) {
|
|
// ...but we always return an array if option alwaysArray is true
|
|
exclusion[key] = [value]
|
|
}
|
|
else {
|
|
// Record its value as a string
|
|
exclusion[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// If we already have seen that keyword...
|
|
if (query[key]) {
|
|
// ...many times...
|
|
if (query[key] instanceof Array) {
|
|
// ...and got several values this time...
|
|
if (values.length > 1) {
|
|
// ... concatenate both arrays.
|
|
query[key] = query[key].concat(values)
|
|
}
|
|
else {
|
|
// ... append the current single value.
|
|
query[key].push(value)
|
|
}
|
|
}
|
|
// We saw that keyword only once before
|
|
else {
|
|
// Put both the current value and the new
|
|
// value in an array
|
|
query[key] = [query[key]]
|
|
query[key].push(value)
|
|
}
|
|
}
|
|
// First time we see that keyword
|
|
else {
|
|
// ...and got several values this time...
|
|
if (values.length > 1) {
|
|
// ...add all values seen.
|
|
query[key] = values
|
|
}
|
|
// Got only a single value this time
|
|
else {
|
|
if (options.alwaysArray) {
|
|
// ...but we always return an array if option alwaysArray is true
|
|
query[key] = [value]
|
|
}
|
|
else {
|
|
// Record its value as a string
|
|
query[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The key allows a range
|
|
else if (isRange) {
|
|
// When offsets is true, push a new offset
|
|
if (options.offsets) {
|
|
query.offsets.push(term)
|
|
}
|
|
|
|
const value = term.value
|
|
// Range are separated with a dash
|
|
const rangeValues = value.split('-')
|
|
// When both end of the range are specified
|
|
// keyword:XXXX-YYYY
|
|
query[key] = {}
|
|
if (rangeValues.length === 2) {
|
|
query[key].from = rangeValues[0]
|
|
query[key].to = rangeValues[1]
|
|
}
|
|
// When pairs of ranges are specified
|
|
// keyword:XXXX-YYYY,AAAA-BBBB
|
|
// else if (!rangeValues.length % 2) {
|
|
// }
|
|
// When only getting a single value,
|
|
// or an odd number of values
|
|
else {
|
|
query[key].from = value
|
|
}
|
|
}
|
|
else {
|
|
// We add it as pure text
|
|
const text = term.keyword + ':' + term.value
|
|
query.text.push(text)
|
|
|
|
// When offsets is true, push a new offset
|
|
if (options.offsets) {
|
|
query.offsets.push({
|
|
text: text,
|
|
offsetStart: term.offsetStart,
|
|
offsetEnd: term.offsetEnd,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return forged query object
|
|
query.exclude = exclusion
|
|
return query
|
|
}
|