Files
Trilium/src/services/search/services/parse.js

428 lines
13 KiB
JavaScript
Raw Normal View History

2020-05-21 11:46:01 +02:00
"use strict";
const dayjs = require("dayjs");
2020-07-21 00:01:07 +02:00
const AndExp = require('../expressions/and.js');
const OrExp = require('../expressions/or.js');
const NotExp = require('../expressions/not.js');
const ChildOfExp = require('../expressions/child_of.js');
const DescendantOfExp = require('../expressions/descendant_of.js');
const ParentOfExp = require('../expressions/parent_of.js');
const RelationWhereExp = require('../expressions/relation_where.js');
const PropertyComparisonExp = require('../expressions/property_comparison.js');
const AttributeExistsExp = require('../expressions/attribute_exists.js');
const LabelComparisonExp = require('../expressions/label_comparison.js');
2020-12-15 15:09:00 +01:00
const NoteCacheFlatTextExp = require('../expressions/note_cache_flat_text.js');
2020-07-21 00:01:07 +02:00
const NoteContentProtectedFulltextExp = require('../expressions/note_content_protected_fulltext.js');
const NoteContentUnprotectedFulltextExp = require('../expressions/note_content_unprotected_fulltext.js');
const OrderByAndLimitExp = require('../expressions/order_by_and_limit.js');
const AncestorExp = require("../expressions/ancestor.js");
const buildComparator = require('./build_comparator.js');
2020-07-21 00:01:07 +02:00
const ValueExtractor = require('../value_extractor.js');
2020-05-19 00:00:35 +02:00
function getFulltext(tokens, searchContext) {
2020-07-19 23:19:45 +02:00
tokens = tokens.map(t => t.token);
searchContext.highlightedTokens.push(...tokens);
2020-05-21 11:18:15 +02:00
2020-05-20 23:20:39 +02:00
if (tokens.length === 0) {
return null;
}
if (!searchContext.fastSearch) {
return new OrExp([
2020-12-15 15:09:00 +01:00
new NoteCacheFlatTextExp(tokens),
2020-06-20 23:46:49 +02:00
new NoteContentProtectedFulltextExp('*=*', tokens),
new NoteContentUnprotectedFulltextExp('*=*', tokens)
2020-05-20 23:20:39 +02:00
]);
2020-05-19 00:00:35 +02:00
}
else {
2020-12-15 15:09:00 +01:00
return new NoteCacheFlatTextExp(tokens);
2020-05-19 00:00:35 +02:00
}
}
function isOperator(str) {
2021-02-17 23:41:15 +01:00
return str.match(/^[!=<>*]+$/);
2020-05-19 00:00:35 +02:00
}
function getExpression(tokens, searchContext, level = 0) {
2020-05-20 23:20:39 +02:00
if (tokens.length === 0) {
return null;
}
2020-05-19 00:00:35 +02:00
const expressions = [];
let op = null;
let i;
function context(i) {
let {startIndex, endIndex} = tokens[i];
startIndex = Math.max(0, startIndex - 20);
endIndex = Math.min(searchContext.originalQuery.length, endIndex + 20);
return '"' + (startIndex !== 0 ? "..." : "")
+ searchContext.originalQuery.substr(startIndex, endIndex - startIndex)
+ (endIndex !== searchContext.originalQuery.length ? "..." : "") + '"';
}
function resolveConstantOperand() {
const operand = tokens[i];
if (!operand.inQuotes
&& (operand.token.startsWith('#') || operand.token.startsWith('~') || operand.token === 'note')) {
searchContext.addError(`Error near token "${operand.token}" in ${context(i)}, it's possible to compare with constant only.`);
return null;
}
if (operand.inQuotes || !["now", "today", "month", "year"].includes(operand.token)) {
return operand.token;
}
let delta = 0;
if (i + 2 < tokens.length) {
if (tokens[i + 1].token === '+') {
i += 2;
delta += parseInt(tokens[i].token);
}
else if (tokens[i + 1].token === '-') {
i += 2;
delta -= parseInt(tokens[i].token);
}
}
let format, date;
if (operand.token === 'now') {
date = dayjs().add(delta, 'second');
format = "YYYY-MM-DD HH:mm:ss";
}
else if (operand.token === 'today') {
date = dayjs().add(delta, 'day');
format = "YYYY-MM-DD";
}
else if (operand.token === 'month') {
date = dayjs().add(delta, 'month');
format = "YYYY-MM";
}
else if (operand.token === 'year') {
date = dayjs().add(delta, 'year');
format = "YYYY";
}
else {
throw new Error("Unrecognized keyword: " + operand.token);
}
return date.format(format);
}
function parseNoteProperty() {
2020-07-19 23:19:45 +02:00
if (tokens[i].token !== '.') {
searchContext.addError('Expected "." to separate field path');
return;
}
i++;
2021-02-14 19:27:31 +01:00
if (['content', 'rawcontent'].includes(tokens[i].token)) {
const raw = tokens[i].token === 'rawcontent';
2020-05-26 23:25:13 +02:00
i += 1;
2020-07-19 23:19:45 +02:00
const operator = tokens[i].token;
2020-05-26 23:25:13 +02:00
if (!isOperator(operator)) {
searchContext.addError(`After content expected operator, but got "${tokens[i].token}" in ${context(i)}`);
2020-05-26 23:25:13 +02:00
return;
}
i++;
2020-06-20 23:46:49 +02:00
return new OrExp([
2021-02-14 19:27:31 +01:00
new NoteContentUnprotectedFulltextExp(operator, [tokens[i].token], raw),
new NoteContentProtectedFulltextExp(operator, [tokens[i].token], raw)
2020-06-20 23:46:49 +02:00
]);
2020-05-26 23:25:13 +02:00
}
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'parents') {
i += 1;
return new ChildOfExp(parseNoteProperty());
2020-05-23 10:36:49 +02:00
}
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'children') {
2020-05-23 10:36:49 +02:00
i += 1;
return new ParentOfExp(parseNoteProperty());
}
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'ancestors') {
2020-05-23 23:44:55 +02:00
i += 1;
return new DescendantOfExp(parseNoteProperty());
}
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'labels') {
if (tokens[i + 1].token !== '.') {
searchContext.addError(`Expected "." to separate field path, got "${tokens[i + 1].token}" in ${context(i)}`);
return;
}
i += 2;
2020-07-19 23:19:45 +02:00
return parseLabel(tokens[i].token);
}
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'relations') {
if (tokens[i + 1].token !== '.') {
searchContext.addError(`Expected "." to separate field path, got "${tokens[i + 1].token}" in ${context(i)}`);
return;
}
i += 2;
2020-07-19 23:19:45 +02:00
return parseRelation(tokens[i].token);
}
2020-08-19 23:00:51 +02:00
if (tokens[i].token === 'text') {
if (tokens[i + 1].token !== '*=*') {
searchContext.addError(`Virtual attribute "note.text" supports only *=* operator, instead given "${tokens[i + 1].token}" in ${context(i)}`);
2020-08-19 23:00:51 +02:00
return;
}
i += 2;
2020-09-24 23:13:27 +02:00
return new OrExp([
new PropertyComparisonExp(searchContext, 'title', '*=*', tokens[i].token),
2020-09-24 23:13:27 +02:00
new NoteContentProtectedFulltextExp('*=*', [tokens[i].token]),
new NoteContentUnprotectedFulltextExp('*=*', [tokens[i].token])
]);
2020-08-19 23:00:51 +02:00
}
2020-07-19 23:19:45 +02:00
if (PropertyComparisonExp.isProperty(tokens[i].token)) {
const propertyName = tokens[i].token;
const operator = tokens[i + 1].token;
i += 2;
const comparedValue = resolveConstantOperand();
return new PropertyComparisonExp(searchContext, propertyName, operator, comparedValue);
}
2020-05-23 20:52:55 +02:00
searchContext.addError(`Unrecognized note property "${tokens[i].token}" in ${context(i)}`);
}
2020-07-19 15:25:24 +02:00
function parseAttribute(name) {
const isLabel = name.startsWith('#');
name = name.substr(1);
const isNegated = name.startsWith('!');
if (isNegated) {
name = name.substr(1);
}
const subExp = isLabel ? parseLabel(name) : parseRelation(name);
return isNegated ? new NotExp(subExp) : subExp;
}
function parseLabel(labelName) {
searchContext.highlightedTokens.push(labelName);
2020-07-19 23:19:45 +02:00
if (i < tokens.length - 2 && isOperator(tokens[i + 1].token)) {
let operator = tokens[i + 1].token;
i += 2;
const comparedValue = resolveConstantOperand();
if (comparedValue === null) {
return;
}
searchContext.highlightedTokens.push(comparedValue);
if (searchContext.fuzzyAttributeSearch && operator === '=') {
operator = '*=*';
}
const comparator = buildComparator(operator, comparedValue);
if (!comparator) {
searchContext.addError(`Can't find operator '${operator}' in ${context(i - 1)}`);
} else {
return new LabelComparisonExp('label', labelName, comparator);
}
} else {
return new AttributeExistsExp('label', labelName, searchContext.fuzzyAttributeSearch);
}
}
function parseRelation(relationName) {
searchContext.highlightedTokens.push(relationName);
2020-07-19 23:19:45 +02:00
if (i < tokens.length - 2 && tokens[i + 1].token === '.') {
i += 1;
return new RelationWhereExp(relationName, parseNoteProperty());
}
else if (i < tokens.length - 2 && isOperator(tokens[i + 1].token)) {
searchContext.addError(`Relation can be compared only with property, e.g. ~relation.title=hello in ${context(i)}`);
return null;
}
else {
return new AttributeExistsExp('relation', relationName, searchContext.fuzzyAttributeSearch);
}
}
2020-05-25 00:25:47 +02:00
function parseOrderByAndLimit() {
const orderDefinitions = [];
let limit;
2020-07-19 23:19:45 +02:00
if (tokens[i].token === 'orderby') {
2020-05-25 00:25:47 +02:00
do {
const propertyPath = [];
let direction = "asc";
do {
i++;
2020-07-19 23:19:45 +02:00
propertyPath.push(tokens[i].token);
2020-05-25 00:25:47 +02:00
i++;
2020-07-19 23:19:45 +02:00
} while (i < tokens.length && tokens[i].token === '.');
2020-05-25 00:25:47 +02:00
2020-07-19 23:19:45 +02:00
if (i < tokens.length && ["asc", "desc"].includes(tokens[i].token)) {
direction = tokens[i].token;
2020-05-25 00:25:47 +02:00
i++;
}
const valueExtractor = new ValueExtractor(searchContext, propertyPath);
2020-05-25 00:25:47 +02:00
if (valueExtractor.validate()) {
searchContext.addError(valueExtractor.validate());
2020-05-25 00:25:47 +02:00
}
orderDefinitions.push({
valueExtractor,
direction
});
2020-07-19 23:19:45 +02:00
} while (i < tokens.length && tokens[i].token === ',');
2020-05-25 00:25:47 +02:00
}
2020-07-19 23:19:45 +02:00
if (i < tokens.length && tokens[i].token === 'limit') {
limit = parseInt(tokens[i + 1].token);
2020-05-25 00:25:47 +02:00
}
return new OrderByAndLimitExp(orderDefinitions, limit);
}
function getAggregateExpression() {
if (op === null || op === 'and') {
return AndExp.of(expressions);
}
else if (op === 'or') {
return OrExp.of(expressions);
}
}
for (i = 0; i < tokens.length; i++) {
2020-07-19 23:19:45 +02:00
if (Array.isArray(tokens[i])) {
expressions.push(getExpression(tokens[i], searchContext, level++));
2020-07-19 23:19:45 +02:00
continue;
}
const token = tokens[i].token;
2020-05-19 00:00:35 +02:00
if (token === '#' || token === '~') {
2020-05-19 00:00:35 +02:00
continue;
}
2020-07-19 23:19:45 +02:00
if (token.startsWith('#') || token.startsWith('~')) {
2020-07-19 15:25:24 +02:00
expressions.push(parseAttribute(token));
2020-05-19 00:00:35 +02:00
}
2020-05-25 00:25:47 +02:00
else if (['orderby', 'limit'].includes(token)) {
if (level !== 0) {
searchContext.addError('orderBy can appear only on the top expression level');
2020-05-25 00:25:47 +02:00
continue;
}
const exp = parseOrderByAndLimit();
if (!exp) {
continue;
}
exp.subExpression = getAggregateExpression();
return exp;
}
2020-05-27 00:09:19 +02:00
else if (token === 'not') {
i += 1;
if (!Array.isArray(tokens[i])) {
searchContext.addError(`not keyword should be followed by sub-expression in parenthesis, got ${tokens[i].token} instead`);
2020-05-27 00:09:19 +02:00
continue;
}
expressions.push(new NotExp(getExpression(tokens[i], searchContext, level++)));
2020-05-27 00:09:19 +02:00
}
else if (token === 'note') {
i++;
expressions.push(parseNoteProperty(tokens));
continue;
}
else if (['and', 'or'].includes(token)) {
2020-05-19 00:00:35 +02:00
if (!op) {
op = token;
2020-05-19 00:00:35 +02:00
}
else if (op !== token) {
searchContext.addError('Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions.');
2020-05-19 00:00:35 +02:00
}
}
else if (isOperator(token)) {
searchContext.addError(`Misplaced or incomplete expression "${token}"`);
2020-05-19 00:00:35 +02:00
}
else {
searchContext.addError(`Unrecognized expression "${token}"`);
2020-05-19 00:00:35 +02:00
}
if (!op && expressions.length > 1) {
op = 'and';
}
}
2020-05-20 00:03:33 +02:00
2020-05-25 00:25:47 +02:00
return getAggregateExpression();
2020-05-19 00:00:35 +02:00
}
function parse({fulltextTokens, expressionTokens, searchContext}) {
let exp = AndExp.of([
searchContext.includeArchivedNotes ? null : new PropertyComparisonExp(searchContext, "isarchived", "=", "false"),
(searchContext.ancestorNoteId && searchContext.ancestorNoteId !== 'root') ? new AncestorExp(searchContext.ancestorNoteId, searchContext.ancestorDepth) : null,
getFulltext(fulltextTokens, searchContext),
getExpression(expressionTokens, searchContext)
2020-05-19 00:00:35 +02:00
]);
if (searchContext.orderBy && searchContext.orderBy !== 'relevancy') {
const filterExp = exp;
exp = new OrderByAndLimitExp([{
valueExtractor: new ValueExtractor(searchContext, ['note', searchContext.orderBy]),
direction: searchContext.orderDirection
}], searchContext.limit);
exp.subExpression = filterExp;
}
return exp;
2020-05-19 00:00:35 +02:00
}
2020-05-20 00:03:33 +02:00
module.exports = parse;