From e22eaca945d0b4c99ca4008aa0fa7e734cc0ab43 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 17 Mar 2025 12:10:54 -0700 Subject: [PATCH] Add order by handling to query language --- libs/common/src/vault/search/ast.ts | 19 ++++- .../vault/search/bitwarden-query-grammar.ne | 39 +++++----- .../vault/search/bitwarden-query-grammar.ts | 46 ++++++++++++ libs/common/src/vault/search/parse.ts | 72 ++++++++++++++++--- 4 files changed, 151 insertions(+), 25 deletions(-) diff --git a/libs/common/src/vault/search/ast.ts b/libs/common/src/vault/search/ast.ts index 50d90769a87..8346a5abc81 100644 --- a/libs/common/src/vault/search/ast.ts +++ b/libs/common/src/vault/search/ast.ts @@ -19,6 +19,7 @@ export const AstNodeTypeNames = [ "type", "website", "websiteMatch", + "orderBy", ] as const; export type AstNodeType = (typeof AstNodeTypeNames)[number]; export type AstNode = @@ -41,7 +42,8 @@ export type AstNode = | IsFavorite | TypeFilter | WebsiteFilter - | WebsiteMatchFilter; + | WebsiteMatchFilter + | OrderBy; type AstNodeBase = { d: object[]; @@ -226,3 +228,18 @@ export type WebsiteMatchFilter = AstNodeBase & { export function isWebsiteMatchFilter(x: AstNode): x is WebsiteMatchFilter { return x.type === "websiteMatch"; } + +export enum OrderDirection { + Asc = "asc", + Desc = "desc", +} + +export type OrderBy = AstNodeBase & { + type: "orderBy"; + field: string; + direction: OrderDirection; +}; + +export function isOrderBy(x: AstNode): x is OrderBy { + return x.type === "orderBy"; +} diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ne b/libs/common/src/vault/search/bitwarden-query-grammar.ne index fe5e2fc3d2f..cc7553912d8 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ne +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ne @@ -17,6 +17,9 @@ let lexer = moo.compile({ func_is: 'is:', func_type: 'type:', func_website: 'website:', + // Ordering functions and parameters + func_order: 'order:', + param_dir: /:(?:asc|desc)/, // function parameter separator access: ':', // string match, includes quoted strings with escaped quotes and backslashes @@ -40,36 +43,40 @@ OR -> OR _ %OR _ AND {% function(d) { return { type: 'or', left TERM -> # naked string search term, search all fields - %string {% function(d) { const start = d[0].offset; const end = d[0].offset + d[0].value.length; return { type: 'term', value: d[0].value, d: d[0], start, end, length: d[0].value.length } } %} + %string {% function(d) { const start = d[0].offset; const end = d[0].offset + d[0].value.length; return { type: 'term', value: d[0].value, d: d[0], start, end, length: d[0].value.length } } %} # specified field search term - | %string %access %string {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'field term', field: d[0].value, term: d[2].value, d: d, start, end, length: end - start + 1 } } %} + | %string %access %string {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'field term', field: d[0].value, term: d[2].value, d: d, start, end, length: end - start + 1 } } %} # only items with attachments - | %func_has "attachment" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasAttachment', d: d, start, end: d[0].offset + length, length } } %} + | %func_has "attachment" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasAttachment', d: d, start, end: d[0].offset + length, length } } %} # only items with URIs - | %func_has "uri" {% function(d) { const start = d[0].offset; const length = 7; return { type: 'hasUri', d: d, start, end: d[0].offset + length, length } } %} + | %func_has "uri" {% function(d) { const start = d[0].offset; const length = 7; return { type: 'hasUri', d: d, start, end: d[0].offset + length, length } } %} # only items assigned to a folder - | %func_has "folder" {% function(d) { const start = d[0].offset; const length = 10; return { type: 'hasFolder', d: d, start, end: d[0].offset + length, length } } %} + | %func_has "folder" {% function(d) { const start = d[0].offset; const length = 10; return { type: 'hasFolder', d: d, start, end: d[0].offset + length, length } } %} # only items assigned to a collection - | %func_has "collection" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasCollection', d: d, start, end: d[0].offset + length, length } } %} + | %func_has "collection" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasCollection', d: d, start, end: d[0].offset + length, length } } %} # only items assigned to a specified folder - | %func_in "folder" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inFolder', folder: d[3].value, d: d, start, end, length: end - start } } %} + | %func_in "folder" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inFolder', folder: d[3].value, d: d, start, end, length: end - start } } %} # only items assigned to a specified collection - | %func_in "collection" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inCollection', collection: d[3].value, d: d, start, end, length: end - start + 1 } } %} + | %func_in "collection" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inCollection', collection: d[3].value, d: d, start, end, length: end - start + 1 } } %} # only items assigned to a specified organization - | %func_in "org" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inOrg', org: d[3].value, d: d, start, end, length: end - start + 1 } } %} + | %func_in "org" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inOrg', org: d[3].value, d: d, start, end, length: end - start + 1 } } %} # only items in personal vault - | %func_in "my_vault" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'inMyVault', d: d, start, end: start + length, length } } %} + | %func_in "my_vault" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'inMyVault', d: d, start, end: start + length, length } } %} # only items in trash - | %func_in "trash" {% function(d) { const start = d[0].offset; const length = 8; return { type: 'inTrash', d: d, start, end: start + length, length } } %} + | %func_in "trash" {% function(d) { const start = d[0].offset; const length = 8; return { type: 'inTrash', d: d, start, end: start + length, length } } %} # only items marked as favorites - | %func_is "favorite" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'isFavorite', d: d, start, end: start + length, length } } %} + | %func_is "favorite" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'isFavorite', d: d, start, end: start + length, length } } %} # only items of given type type - | %func_type %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'type', d:d, cipherType: d[1].value, start, end, length: end - start + 1 } } %} + | %func_type %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'type', d:d, cipherType: d[1].value, start, end, length: end - start + 1 } } %} # only items with a specified website - | %func_website %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'website', d: d, website: d[1].value, start, end, length: end - start + 1 } } %} + | %func_website %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'website', d: d, website: d[1].value, start, end, length: end - start + 1 } } %} # only items with a specified website and a given match pattern - | %func_website %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'websiteMatch', d: d, website: d[1].value, matchType: d[3].value, start, end, length: end - start + 1 } } %} + | %func_website %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'websiteMatch', d: d, website: d[1].value, matchType: d[3].value, start, end, length: end - start + 1 } } %} + # order by name + | %func_order %param_dir {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'orderBy', d: d, field: 'name', direction: d[1].value.substring(1,d[1].value.length).toLowerCase(), start, end, length: end - start + 1 } } %} + # order by a specified field + | %func_order %string %param_dir {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'orderBy', d: d, field: d[1].value, direction: d[2].value.substring(1,d[2].value.length).toLowerCase(), start, end, length: end - start + 1 } } %} # Boolean NOT operator - | %NOT _ PARENTHESES {% function(d) { const start = d[0].offset; return { type: 'not', value: d[2], d: d, start, end: d[2].end, length: d[2].end - d[0].offset + 1 } } %} + | %NOT _ PARENTHESES {% function(d) { const start = d[0].offset; return { type: 'not', value: d[2], d: d, start, end: d[2].end, length: d[2].end - d[0].offset + 1 } } %} _ -> %WS:* {% function(d) {return null } %} diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ts b/libs/common/src/vault/search/bitwarden-query-grammar.ts index 48e34f5f89f..feaabb7a401 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ts +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ts @@ -16,6 +16,8 @@ declare var func_in: any; declare var func_is: any; declare var func_type: any; declare var func_website: any; +declare var func_order: any; +declare var param_dir: any; declare var NOT: any; declare var WS: any; @@ -36,6 +38,9 @@ let lexer = moo.compile({ func_is: "is:", func_type: "type:", func_website: "website:", + // Ordering functions and parameters + func_order: "order:", + param_dir: /:(?:asc|desc)/, // function parameter separator access: ":", // string match, includes quoted strings with escaped quotes and backslashes @@ -337,6 +342,47 @@ const grammar: Grammar = { }; }, }, + { + name: "TERM", + symbols: [ + lexer.has("func_order") ? { type: "func_order" } : func_order, + lexer.has("param_dir") ? { type: "param_dir" } : param_dir, + ], + postprocess: function (d) { + const start = d[0].offset; + const end = d[1].offset + d[1].value.length; + return { + type: "orderBy", + d: d, + field: "name", + direction: d[1].value.substring(1, d[1].value.length).toLowerCase(), + start, + end, + length: end - start + 1, + }; + }, + }, + { + name: "TERM", + symbols: [ + lexer.has("func_order") ? { type: "func_order" } : func_order, + lexer.has("string") ? { type: "string" } : string, + lexer.has("param_dir") ? { type: "param_dir" } : param_dir, + ], + postprocess: function (d) { + const start = d[0].offset; + const end = d[2].offset + d[2].value.length; + return { + type: "orderBy", + d: d, + field: d[1].value, + direction: d[2].value.substring(1, d[2].value.length).toLowerCase(), + start, + end, + length: end - start + 1, + }; + }, + }, { name: "TERM", symbols: [lexer.has("NOT") ? { type: "NOT" } : NOT, "_", "PARENTHESES"], diff --git a/libs/common/src/vault/search/parse.ts b/libs/common/src/vault/search/parse.ts index b535a2d0afa..60f09275c43 100644 --- a/libs/common/src/vault/search/parse.ts +++ b/libs/common/src/vault/search/parse.ts @@ -2,11 +2,13 @@ import { Parser, Grammar } from "nearley"; import { UriMatchStrategy, UriMatchStrategySetting } from "../../models/domain/domain-service"; import { Utils } from "../../platform/misc/utils"; +import { CipherId } from "../../types/guid"; import { CardLinkedId, CipherType, FieldType, LinkedIdType, LoginLinkedId } from "../enums"; import { CipherView } from "../models/view/cipher.view"; import { AstNode, + OrderDirection, isAnd, isFieldTerm, isHasAttachment, @@ -20,6 +22,7 @@ import { isIsFavorite, isNot, isOr, + isOrderBy, isParentheses, isSearch, isTerm, @@ -367,6 +370,55 @@ function handleNode(node: AstNode): ProcessInstructions { }, ], }; + } else if (isOrderBy(node)) { + // TODO: This logic is shaky at best, this operator needs to be rewritten + const fieldTest = fieldNameToRegexTest(node.field); + return { + filter: (context) => { + const idOrder = context.ciphers + .map((cipher) => fieldValues(cipher, /.*/i)) + .sort((a, b) => { + const aValue = a.fields.find((v) => + fieldTest.test(v.path.split(".").reverse()[0]), + )?.value; + const bValue = b.fields.find((v) => + fieldTest.test(v.path.split(".").reverse()[0]), + )?.value; + if (aValue === bValue) { + return 0; + } + if (node.direction === OrderDirection.Asc) { + if (aValue === undefined) { + return 1; + } + if (bValue === undefined) { + return -1; + } + return aValue.localeCompare(bValue) ? -1 : 1; + } else { + if (aValue === undefined) { + return -1; + } + if (bValue === undefined) { + return 1; + } + return aValue.localeCompare(bValue) ? 1 : -1; + } + }) + .map((fieldValues) => fieldValues.id); + return { + ...context, + ciphers: idOrder.map((id) => context.ciphers.find((cipher) => cipher.id === id)!), + }; + }, + sections: [ + { + start: node.start, + end: node.end, + type: node.type, + }, + ], + }; } else { throw new Error("Invalid node\n" + JSON.stringify(node, null, 2)); } @@ -409,7 +461,7 @@ function matchEnum( function hasTerm(cipher: CipherView, termTest: RegExp, fieldTest: RegExp = /.*/i): boolean { const foundValues = fieldValues(cipher, fieldTest); - return foundValues.some((foundValue) => termTest.test(foundValue.value)); + return foundValues.fields.some((foundValue) => termTest.test(foundValue.value)); } function termToRegexTest(term: string) { @@ -443,15 +495,18 @@ const ForbiddenLinkedIds: Readonly = Object.freeze([ ]); type FieldValues = { path: string; value: string }[]; -function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues { - const result = recursiveValues(cipher, fieldTest, ""); +function fieldValues(cipher: CipherView, fieldTest: RegExp): { id: CipherId; fields: FieldValues } { + const result = { + id: cipher.id as CipherId, + fields: recursiveValues(cipher, fieldTest, ""), + }; // append custom fields for (const field of cipher.fields ?? []) { switch (field.type) { case FieldType.Text: if (fieldTest.test(field.name)) { - result.push({ + result.fields.push({ path: `customField.${field.name}`, value: field.value, }); @@ -467,7 +522,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues { break; } if (fieldTest.test(field.name) && value != null) { - result.push({ + result.fields.push({ path: `customField.${field.name}`, value: value, }); @@ -476,7 +531,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues { } case FieldType.Boolean: { if (fieldTest.test(field.name)) { - result.push({ + result.fields.push({ path: `customField.${field.name}`, value: field.value, }); @@ -491,7 +546,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues { // append attachments if (fieldTest.test("fileName")) { cipher.attachments?.forEach((a) => { - result.push({ + result.fields.push({ path: `attachment.fileName`, value: a.fileName, }); @@ -499,9 +554,10 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues { } // Purge forbidden paths from results - return result.filter(({ path }) => { + result.fields = result.fields.filter(({ path }) => { return !ForbiddenFields.includes(path); }); + return result; } function recursiveValues(obj: T, fieldTest: RegExp, crumb: string): FieldValues {