search-string
Version:
Another parser for advanced search query syntax.
310 lines • 11.5 kB
JavaScript
import { getQuotePairMap } from './utils.js';
// State tokens
const RESET = 'RESET';
const IN_OPERAND = 'IN_OPERAND';
const IN_TEXT = 'IN_TEXT';
const SINGLE_QUOTE = 'SINGLE_QUOTE';
const DOUBLE_QUOTE = 'DOUBLE_QUOTE';
/**
* **SearchString** is a parsed search string which allows you to fetch conditions
* and text being searched.
*/
export default class SearchString {
/**
* Not intended for public use. API could change.
*/
constructor(conditionArray, textSegments) {
this.conditionArray = conditionArray;
this.textSegments = textSegments;
this.string = '';
this.isStringDirty = true;
}
/**
* @param str - String to parse e.g. `'to:me -from:joe@acme.com foobar'`.
* @param transformTextToConditions - Array of functions to transform text into conditions.
* @returns An instance of the **SearchString** class.
*/
static parse(str, transformTextToConditions = []) {
if (!str)
str = '';
const conditionArray = [];
const textSegments = [];
const addCondition = (key, value, negated) => {
const arrayEntry = { keyword: key, value, negated };
conditionArray.push(arrayEntry);
};
const addTextSegment = (text, negated) => {
let hasTransform = false;
transformTextToConditions.forEach((transform) => {
const { key, value } = transform(text);
if (key && value) {
addCondition(key, value, negated);
hasTransform = true;
}
});
if (!hasTransform) {
textSegments.push({ text, negated });
}
};
let state;
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(str);
for (let i = 0; i < str.length; i++) {
const char = str[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 !== null && char !== void 0 ? char : '';
}
// End of string, add any last entries
if (inText()) {
addTextSegment(currentText, isNegated);
}
else if (inOperand()) {
addCondition(currentText, currentOperand, isNegated);
}
return new SearchString(conditionArray, textSegments);
}
/**
* @returns Conditions array, may contain multiple conditions for a particular key.
*/
getConditionArray() {
return this.conditionArray;
}
/**
* @returns Map of conditions and includes a special key `'exclude'`.
* `'exclude'` itself is a map of conditions which were negated.
*/
getParsedQuery() {
const parsedQuery = { exclude: {} };
this.conditionArray.forEach((condition) => {
var _a;
if (condition.negated) {
if (parsedQuery.exclude[condition.keyword]) {
(_a = parsedQuery.exclude[condition.keyword]) === null || _a === void 0 ? void 0 : _a.push(condition.value);
}
else {
parsedQuery.exclude[condition.keyword] = [condition.value];
}
}
else {
if (parsedQuery[condition.keyword]) {
parsedQuery[condition.keyword].push(condition.value);
}
else {
parsedQuery[condition.keyword] = [condition.value];
}
}
});
return parsedQuery;
}
/**
* @returns All text segments concatenated together joined by a space.
* If a text segment is negated, it is preceded by a `-`.
*/
getAllText() {
return this.textSegments
? this.textSegments.map(({ text, negated }) => (negated ? `-${text}` : text)).join(' ')
: '';
}
/**
* @returns All text segment objects, negative or positive.
* E.g., `{ text: 'foobar', negated: false }`
*/
getTextSegments() {
return this.textSegments;
}
/**
* Removes keyword-negated pair that matches inputted.
* Only removes if entry has same keyword/negated combo.
* @param keywordToRemove - Keyword to remove.
* @param negatedToRemove - Whether or not the keyword removed is negated.
*/
removeKeyword(keywordToRemove, negatedToRemove) {
this.conditionArray = this.conditionArray.filter(({ keyword, negated }) => keywordToRemove !== keyword || negatedToRemove !== negated);
this.isStringDirty = true;
}
/**
* Adds a new entry to search string. Does not dedupe against existing entries.
* @param keyword - Keyword to add.
* @param value - Value for respective keyword.
* @param negated - Whether or not keyword/value pair should be negated.
*/
addEntry(keyword, value, negated) {
this.conditionArray.push({
keyword,
value,
negated,
});
this.isStringDirty = true;
}
/**
* Removes an entry from the search string. If more than one entry with the same settings is found,
* it removes the first entry matched.
*
* @param keyword - Keyword to remove.
* @param value - Value for respective keyword.
* @param negated - Whether or not keyword/value pair is negated.
*/
removeEntry(keyword, value, negated) {
const index = this.conditionArray.findIndex((entry) => {
return entry.keyword === keyword && entry.value === value && entry.negated === negated;
});
if (index === -1)
return;
this.conditionArray.splice(index, 1);
this.isStringDirty = true;
}
/**
* @returns A new instance of this class based on current data.
*/
clone() {
return new SearchString([...this.conditionArray], [...this.textSegments]);
}
/**
* @returns Returns this instance synthesized to a string format.
* Example string: `'to:me -from:joe@acme.com foobar'`
*/
toString() {
if (this.isStringDirty) {
// Group keyword, negated pairs as keys
const conditionGroups = {};
this.conditionArray.forEach(({ keyword, value, negated }) => {
const negatedStr = negated ? '-' : '';
const conditionGroupKey = `${negatedStr}${keyword}`;
if (conditionGroups[conditionGroupKey]) {
conditionGroups[conditionGroupKey].push(value);
}
else {
conditionGroups[conditionGroupKey] = [value];
}
});
// Build conditionStr
let conditionStr = '';
Object.entries(conditionGroups).forEach(([conditionGroupKey, values]) => {
const safeValues = values
.filter((v) => v)
.map((v) => {
let newV = '';
let shouldQuote = false;
for (let i = 0; i < v.length; i++) {
const char = v[i];
if (char === '"') {
newV += '\\"';
}
else {
if (char === ' ' || char === ',') {
shouldQuote = true;
}
newV += char;
}
}
return shouldQuote ? `"${newV}"` : newV;
});
if (safeValues.length > 0) {
conditionStr += ` ${conditionGroupKey}:${safeValues.join(',')}`;
}
});
this.string = `${conditionStr} ${this.getAllText()}`.trim();
this.isStringDirty = false;
}
return this.string;
}
}
//# sourceMappingURL=searchString.js.map