wildduck/lib/search-query.js
Andris Reinman ecf362c67d fixed tests
2023-07-20 13:59:59 +03:00

590 lines
22 KiB
JavaScript

'use strict';
const SearchString = require('search-string');
const parser = require('logic-query-parser');
const { escapeRegexStr } = require('./tools');
const { ObjectId } = require('mongodb');
function parseSearchQuery(queryStr) {
const queryTree = parser.parse(queryStr);
let result = [];
let walkQueryTree = (node, branch, opts) => {
switch (node.lexeme && node.lexeme.type) {
case 'and': {
let leafNode;
if (opts.condType === 'and') {
leafNode = branch;
} else {
let node = { $and: [] };
branch.push(node);
leafNode = node.$and;
}
if (node.left) {
if (
node.left?.lexeme?.type === 'string' &&
typeof node.left.lexeme.value === 'string' &&
node.left.lexeme.value.length > 1 &&
node.left.lexeme.value.at(-1) === ':' &&
node.right?.lexeme?.type === 'and' &&
node.right.left?.lexeme?.type === 'string' &&
node.right.left.lexeme.value
) {
//
node.left.lexeme.value += `"${node.right.left.lexeme.value}"`;
node.right = node.right.right;
}
walkQueryTree(
node.left,
leafNode,
Object.assign({}, opts, {
condType: 'and'
})
);
}
if (node.right) {
walkQueryTree(
node.right,
leafNode,
Object.assign({}, opts, {
condType: 'and'
})
);
}
return;
}
case 'or': {
let leafNode;
if (opts.condType === 'or') {
leafNode = branch;
} else {
let node = { $or: [] };
branch.push(node);
leafNode = node.$or;
}
if (node.left) {
walkQueryTree(
node.left,
leafNode,
Object.assign({}, opts, {
condType: 'or'
})
);
}
if (node.right) {
walkQueryTree(
node.right,
leafNode,
Object.assign({}, opts, {
condType: 'or'
})
);
}
return;
}
case 'string':
{
const searchString = SearchString.parse(`${opts.negated ? '-' : ''}${node.lexeme.value}`);
let parsedQuery = searchString.getParsedQuery();
node.parsed = { searchString, parsedQuery };
let keywords = {};
if (parsedQuery) {
for (let key of Object.keys(parsedQuery)) {
if (key === 'exclude') {
for (let subKey of Object.keys(parsedQuery[key])) {
keywords[subKey] = { value: parsedQuery[key][subKey].flatMap(entry => entry).shift(), negated: true };
}
} else if (Array.isArray(parsedQuery[key])) {
keywords[key] = { value: parsedQuery[key].flatMap(entry => entry).shift(), negated: false };
}
}
}
let negated = opts.negated;
let textValue =
searchString
.getTextSegments()
.flatMap(entry => {
negated = entry.negated ? !opts.negated : !!opts.negated;
return entry.text;
})
.join(' ') || null;
const leafNode = {
text: textValue ? { value: textValue, negated } : null,
keywords: Object.keys(keywords).length ? keywords : null,
value: node.lexeme.value
};
branch.push(leafNode);
}
break;
}
};
walkQueryTree(queryTree, result, { condType: 'and' });
return result;
}
const getMongoDBQuery = async (db, user, queryStr) => {
const parsed = parseSearchQuery(queryStr);
let hasTextFilter = false;
let walkTree = async node => {
if (Array.isArray(node)) {
let branches = [];
for (let entry of node) {
branches.push(await walkTree(entry));
}
return branches;
}
if (node.$and && node.$and.length) {
let branch = {
$and: []
};
for (let entry of node.$and) {
let subBranch = await walkTree(entry);
branch.$and = branch.$and.concat(subBranch || []);
}
return branch;
} else if (node.$or && node.$or.length) {
let branch = {
$or: []
};
for (let entry of node.$or) {
let subBranch = await walkTree(entry);
branch.$or = branch.$or.concat(subBranch || []);
}
return branch;
} else if (node.text) {
let branch = {
$text: {
$search: node.text.value
}
};
if (node.text.negated) {
branch = { $not: branch };
}
hasTextFilter = true;
return branch;
} else if (node.keywords) {
let branches = [];
let keyword = Object.keys(node.keywords || {}).find(key => key && key !== 'negated');
if (keyword) {
let { value, negated } = node.keywords[keyword];
switch (keyword) {
case 'from':
case 'subject':
{
let regex = escapeRegexStr(value);
let branch = {
headers: {
$elemMatch: {
key: keyword,
value: {
$regex: regex,
$options: 'i'
}
}
}
};
if (negated) {
branch = { $not: branch };
}
branches.push(branch);
}
break;
case 'to':
{
let regex = escapeRegexStr(value);
for (let toKey of ['to', 'cc', 'bcc']) {
let branch = {
headers: {
$elemMatch: {
key: toKey,
value: {
$regex: regex,
$options: 'i'
}
}
}
};
if (negated) {
branch = { $not: branch };
}
branches.push(branch);
}
}
break;
case 'in': {
value = (value || '').toString().trim();
let resolveQuery = { user, $or: [] };
if (/^[0-9a-f]{24}$/i.test(value)) {
resolveQuery.$or.push({ _id: new ObjectId(value) });
} else if (/^Inbox$/i.test(value)) {
resolveQuery.$or.push({ path: 'INBOX' });
} else {
resolveQuery.$or.push({ path: value });
if (/^\/?(spam|junk)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Junk' });
} else if (/^\/?(sent)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Sent' });
} else if (/^\/?(trash|deleted)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Trash' });
} else if (/^\/?(drafts)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Drafts' });
}
}
let mailboxEntry = await db.database.collection('mailboxes').findOne(resolveQuery, { project: { _id: -1 } });
let branch = { mailbox: mailboxEntry ? mailboxEntry._id : new ObjectId('0'.repeat(24)) };
if (negated) {
branch = { $not: branch };
}
branches.push(branch);
break;
}
case 'thread':
{
value = (value || '').toString().trim();
if (/^[0-9a-f]{24}$/i.test(value)) {
let branch = { thread: new ObjectId(value) };
if (negated) {
branch = { $not: branch };
}
branches.push(branch);
}
}
break;
case 'has': {
switch (value) {
case 'attachment': {
branches.push({ ha: true });
break;
}
}
}
}
}
return branches;
}
};
if (parsed && parsed.length) {
let filter = await walkTree(Array.isArray(parsed) ? { $and: parsed } : parsed);
let extras = { user };
if (hasTextFilter) {
extras.searchable = true;
}
return Object.assign({ user: null }, filter, extras);
}
return { user: false };
};
const getElasticSearchQuery = async (db, user, queryStr) => {
const parsed = parseSearchQuery(queryStr);
let searchQuery = {
bool: {
must: [
{
term: {
user: (user || '').toString().trim()
}
}
]
}
};
let walkTree = async node => {
if (Array.isArray(node)) {
let branches = [];
for (let entry of node) {
branches.push(await walkTree(entry));
}
return branches;
}
if (node.$and && node.$and.length) {
let branch = {
bool: { must: [] }
};
for (let entry of node.$and) {
let subBranch = await walkTree(entry);
branch.bool.must = branch.bool.must.concat(subBranch || []);
}
return branch;
} else if (node.$or && node.$or.length) {
let branch = {
bool: { should: [], minimum_should_match: 1 }
};
for (let entry of node.$or) {
let subBranch = await walkTree(entry);
branch.bool.should = branch.bool.should.concat(subBranch || []);
}
return branch;
} else if (node.text) {
let branch = {
bool: {
should: [
{
match: {
subject: {
query: node.text.value,
operator: 'and'
}
}
},
{
match: {
text: {
query: node.text.value,
operator: 'and'
}
}
},
{
match: {
html: {
query: node.text.value,
operator: 'and'
}
}
}
],
minimum_should_match: 1
}
};
if (node.text.negated) {
branch = { bool: { must_not: branch.bool.should } };
}
return branch;
} else if (node.keywords) {
let branches = [];
let keyword = Object.keys(node.keywords || {}).find(key => key && key !== 'negated');
if (keyword) {
let { value, negated } = node.keywords[keyword];
switch (keyword) {
case 'subject':
{
let branch = {
match: {
subject: {
query: value,
operator: 'and'
}
}
};
if (negated) {
branch = { bool: { must_not: branch } };
}
branches.push(branch);
}
break;
case 'from':
{
let branch = {
bool: {
should: [
{
match: {
[`from.name`]: {
query: value,
operator: 'and'
}
}
},
{
term: {
[`from.address`]: value
}
}
],
minimum_should_match: 1
}
};
if (negated) {
branch = { bool: { must_not: branch } };
}
branches.push(branch);
}
break;
case 'to':
{
let branch = {
bool: {
should: [],
minimum_should_match: 1
}
};
for (let toKey of ['to', 'cc', 'bcc']) {
branch.bool.should.push(
{
match: {
[`${toKey}.name`]: {
query: value,
operator: 'and'
}
}
},
{
term: {
[`${toKey}.address`]: value
}
}
);
}
if (negated) {
branch = { bool: { must_not: branch } };
}
branches.push(branch);
}
break;
case 'in': {
value = (value || '').toString().trim();
let resolveQuery = { user, $or: [] };
if (/^[0-9a-f]{24}$/i.test(value)) {
resolveQuery.$or.push({ _id: new ObjectId(value) });
} else if (/^Inbox$/i.test(value)) {
resolveQuery.$or.push({ path: 'INBOX' });
} else {
resolveQuery.$or.push({ path: value });
if (/^\/?(spam|junk)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Junk' });
} else if (/^\/?(sent)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Sent' });
} else if (/^\/?(trash|deleted)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Trash' });
} else if (/^\/?(drafts)/i.test(value)) {
resolveQuery.$or.push({ specialUse: '\\Drafts' });
}
}
let mailboxEntry = await db.database.collection('mailboxes').findOne(resolveQuery, { project: { _id: -1 } });
let branch = { term: { mailbox: (mailboxEntry ? mailboxEntry._id : new ObjectId('0'.repeat(24))).toString() } };
if (negated) {
branch = { bool: { must_not: [branch] } };
}
branches.push(branch);
break;
}
case 'thread':
{
value = (value || '').toString().trim();
if (/^[0-9a-f]{24}$/i.test(value)) {
let branch = { term: { thread: value } };
if (negated) {
branch = { bool: { must_not: [branch] } };
}
if (negated) {
branch = { bool: { must_not: [branch] } };
}
branches.push(branch);
}
}
break;
case 'has': {
switch (value) {
case 'attachment': {
branches.push({ term: { ha: true } });
break;
}
}
}
}
}
return branches;
}
};
if (parsed && parsed.length) {
let filter = await walkTree({ $and: parsed });
searchQuery.bool.must = searchQuery.bool.must.concat(filter);
}
return searchQuery;
};
module.exports = { parseSearchQuery, getMongoDBQuery, getElasticSearchQuery };
/*
if (process.env.DEBUG_TEST_QUERY && process.env.NODE_ENV !== 'production') {
const util = require('util'); // eslint-disable-line
let main = () => {
let db = require('./db'); // eslint-disable-line
db.connect(() => {
let run = async () => {
let queries = ['from:"amy namy" kupi in:spam to:greg has:attachment -subject:"dinner and movie tonight" (jupi OR subject:tere)'];
for (let query of queries) {
console.log('PARSED QUERY');
console.log(util.inspect({ query, parsed: parseSearchQuery(query) }, false, 22, true));
console.log('MongoDB');
console.log(util.inspect({ query, filter: await getMongoDBQuery(db, new ObjectId('64099fff101ca2ef6aad8be7'), query) }, false, 22, true));
console.log('ElasticSearch');
console.log(
util.inspect({ query, filter: await getElasticSearchQuery(db, new ObjectId('64099fff101ca2ef6aad8be7'), query) }, false, 22, true)
);
}
};
run()
.catch(err => console.error(err))
.finally(() => process.exit());
});
};
main();
}
*/