UNPKG

advanced-search-query

Version:

Another parser for advanced search query syntax.

333 lines 10.8 kB
import groupBy from 'lodash.groupby'; import { getQuotePairMap } from './utils'; const RESET = 'RESET'; const IN_OPERAND = 'IN_OPERAND'; const IN_TEXT = 'IN_TEXT'; const SINGLE_QUOTE = 'SINGLE_QUOTE'; const DOUBLE_QUOTE = 'DOUBLE_QUOTE'; export class AdvancedSearchQuery { /** * Not intended for public use. API could change. */ constructor(keywords, texts) { this.keywords = keywords; this.texts = texts; this.input = ''; this.isDirty = true; } getText() { return this.texts ? this.texts .map(({ value, isNegated }) => (isNegated ? `-${value}` : value)) .join(' ') : ''; } getTexts() { return this.texts; } getKeywords() { const keywords = groupBy(this.keywords, 'name'); return Object.entries(keywords) .map(([name, keywords]) => { return [ name, keywords.map((keyword) => { return Object.assign(Object.assign({}, keyword), { name: undefined }); }), ]; }) .reduce((cumulatedKeywords, [name, keywords]) => { return Object.assign(Object.assign({}, cumulatedKeywords), { [name]: keywords }); }, {}); } getKeyword(name) { const keyword = this.getKeywords()[name]; if (!keyword) { return []; } return keyword; } toObject() { const parsedQuery = { text: { include: this.getTexts() .filter((text) => { return text.isNegated === false; }) .map((text) => { return text.value; }), exclude: this.getTexts() .filter((text) => { return text.isNegated === true; }) .map((text) => { return text.value; }), }, keywords: {}, }; this.keywords.forEach((keyword) => { parsedQuery.keywords[keyword.name] = parsedQuery.keywords[keyword.name] || { include: [], exclude: [], }; if (keyword.isNegated) { parsedQuery.keywords[keyword.name].exclude.push(keyword.value); } else { parsedQuery.keywords[keyword.name].include.push(keyword.value); } }); return parsedQuery; } addKeyword(name, value, isNegated = false) { this.keywords.push({ name, value, isNegated, }); this.isDirty = true; return this; } removeKeywords() { this.keywords = []; this.isDirty = true; return this; } removeKeyword(name, value, isNegated) { const keywords = this.keywords.filter((keyword) => { if (name !== keyword.name) { return true; } if (typeof value !== 'undefined' && value !== keyword.value) { return true; } if (typeof isNegated !== 'undefined' && isNegated !== keyword.isNegated) { return true; } return false; }); this.isDirty = keywords.length !== this.keywords.length; this.keywords = keywords; return this; } addText(value, isNegated = false) { this.texts.push({ value, isNegated, }); this.isDirty = true; return this; } removeText(value, isNegated) { const texts = this.texts.filter((text) => { if (value !== text.value) { return true; } if (typeof isNegated !== 'undefined' && isNegated !== text.isNegated) { return true; } return false; }); this.isDirty = texts.length !== this.texts.length; this.texts = texts; return this; } removeTexts() { this.texts = []; this.isDirty = true; return this; } clone() { return new AdvancedSearchQuery(this.keywords.slice(0), this.texts.slice(0)); } toString() { if (!this.isDirty && this.output) { return this.output; } // Group keyword, isNegated pairs as keys const conditionGroups = {}; this.keywords.forEach(({ name, value, isNegated }) => { const isNegatedPrefix = isNegated ? '-' : ''; const key = `${isNegatedPrefix}${name}`; conditionGroups[key] = conditionGroups[key] || []; conditionGroups[key].push(value); }); // Build condition let condition = ''; Object.keys(conditionGroups).forEach((conditionGroupKey) => { const values = conditionGroups[conditionGroupKey]; const safeValues = values.filter(Boolean).map((value) => { let newV = ''; let shouldQuote = false; for (let i = 0; i < value.length; i++) { const char = value[i]; if (char === '"') { newV += '\\"'; } else { if (char === ' ' || char === ',') { shouldQuote = true; } newV += char; } } return shouldQuote ? `"${newV}"` : newV; }); if (safeValues.length > 0) { condition += ` ${conditionGroupKey}:${safeValues.join(',')}`; } }); this.output = `${condition} ${this.getText()}`.trim(); this.isDirty = false; return this.output; } } /** * @param {String} string to parse e.g. 'to:me -from:joe@acme.com foobar'. * @param {Array} transformTextToConditions Array of functions to transform text into conditions * @returns {AdvancedSearchQuery} An instance of class AdvancedSearchQuery. */ export default function (input = '', transformTextToKeywords = []) { const keywords = []; const texts = []; const addCondition = (name, value, isNegated) => { keywords.push({ name, value, isNegated, }); }; const addTextSegment = (value, isNegated) => { let hasTransform = false; transformTextToKeywords.forEach((transform) => { const transformed = transform(value); if (transformed) { const { name, value } = transformed; if (name && value) { addCondition(name, value, isNegated); hasTransform = true; } } }); if (!hasTransform) { texts.push({ value, isNegated }); } }; let state = RESET; let currentOperand = ''; let isNegated = false; let currentText = ''; let quoteState; let prevChar = ''; const performReset = () => { state = RESET; quoteState = RESET; currentOperand = ''; currentText = ''; isNegated = false; prevChar = ''; }; // Terminology, in this example: 'to:joe@acme.com' // 'to' is the operator // 'joe@acme.com' is the operand // 'to:joe@acme.com' is the condition // Possible states: const inText = () => state === IN_TEXT; // could be inside raw text or operator const inOperand = () => state === IN_OPERAND; const inSingleQuote = () => quoteState === SINGLE_QUOTE; const inDoubleQuote = () => quoteState === DOUBLE_QUOTE; const inQuote = () => inSingleQuote() || inDoubleQuote(); performReset(); const quotePairMap = getQuotePairMap(input); for (let i = 0; i < input.length; i++) { const char = input[i]; if (char === ' ') { if (inOperand()) { if (inQuote()) { currentOperand += char; } else { addCondition(currentText, currentOperand, isNegated); performReset(); } } else if (inText()) { if (inQuote()) { currentText += char; } else { addTextSegment(currentText, isNegated); performReset(); } } } else if (char === ',' && inOperand() && !inQuote()) { addCondition(currentText, currentOperand, isNegated); // No reset here because we are still evaluating operands for the same operator currentOperand = ''; } else if (char === '-' && !inOperand() && !inText()) { isNegated = true; } else if (char === ':' && !inQuote()) { if (inOperand()) { // If we're in an operand, just push the string on. currentOperand += char; } else if (inText()) { // Skip this char, move states into IN_OPERAND, state = IN_OPERAND; } } else if (char === '"' && prevChar !== '\\' && !inSingleQuote()) { if (inDoubleQuote()) { quoteState = RESET; } else if (quotePairMap.double[i]) { quoteState = DOUBLE_QUOTE; } else if (inOperand()) { currentOperand += char; } else { currentText += char; } } else if (char === "'" && prevChar !== '\\' && !inDoubleQuote()) { if (inSingleQuote()) { quoteState = RESET; } else if (quotePairMap.single[i]) { quoteState = SINGLE_QUOTE; } else if (inOperand()) { currentOperand += char; } else { currentText += char; } } else if (char !== '\\') { // Regular character.. if (inOperand()) { currentOperand += char; } else { currentText += char; state = IN_TEXT; } } prevChar = char; } // End of string, add any last entries if (inText()) { addTextSegment(currentText, isNegated); } else if (inOperand()) { addCondition(currentText, currentOperand, isNegated); } return new AdvancedSearchQuery(keywords, texts); } //# sourceMappingURL=index.js.map