From b8ba35f5768e6e87186f6f2916680d1f7833bc80 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 20 Mar 2025 06:55:24 -0700 Subject: [PATCH 1/2] PoC: filter service can tag ciphers --- .../vault/individual-vault/vault.component.ts | 35 +++++- .../common/src/vault/search/filter.service.ts | 101 +++++++++++++++--- libs/common/src/vault/search/query.types.ts | 7 ++ 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 9a8972c9123..79c4d6c84e4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -25,6 +25,7 @@ import { takeUntil, tap, catchError, + combineLatestWith, } from "rxjs/operators"; import { @@ -66,6 +67,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FilterService } from "@bitwarden/common/vault/search/filter.service"; +import { SavedFiltersService } from "@bitwarden/common/vault/search/saved-filters.service"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { CardComponent, DialogService, Icons, ToastService } from "@bitwarden/components"; import { @@ -274,6 +276,7 @@ export class VaultComponent implements OnInit, OnDestroy { private organizationBillingService: OrganizationBillingServiceAbstraction, private billingNotificationService: BillingNotificationService, private folderService: FolderService, + private savedFilterService: SavedFiltersService, ) {} async ngOnInit() { @@ -356,12 +359,22 @@ export class VaultComponent implements OnInit, OnDestroy { }); }), ); + const savedFilters$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.savedFilterService.filtersFor$(account.id)), + map((filters) => { + const savedFilters: [string, string][] = []; + for (const [key, value] of Object.entries(filters ?? {})) { + savedFilters.push([key, value]); + } + return savedFilters; + }), + ); const ciphers$ = combineLatest([this.currentSearchText$, context$]).pipe( - switchMap(([currentText, context]) => { - return this.filterService.filter(of([currentText, context])); - }), - // this.filterService.filter, + // switchMap(([currentText, context]) => { + // return this.filterService.filter(of([currentText, context])); + // }), + this.filterService.filter, switchMap((result) => { if (result.isError) { // return all ciphers @@ -371,6 +384,20 @@ export class VaultComponent implements OnInit, OnDestroy { return of(result.ciphers); } }), + combineLatestWith(context$), + map(([filtered, prevContext]) => { + prevContext.ciphers = filtered; + return prevContext; + }), + combineLatestWith(savedFilters$), + switchMap(([context, savedFilters]) => { + return this.filterService.tag(of([savedFilters, context])); + }), + // this.filterService.tag, + map((result) => { + // ignore tagging errors for now + return result.ciphers; + }), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/libs/common/src/vault/search/filter.service.ts b/libs/common/src/vault/search/filter.service.ts index 190e317efab..010f61a4cf8 100644 --- a/libs/common/src/vault/search/filter.service.ts +++ b/libs/common/src/vault/search/filter.service.ts @@ -9,14 +9,25 @@ import { CipherView } from "../models/view/cipher.view"; import { parseQuery } from "./parse"; import { FilterResult, + FilterTagged, ObservableSearchContextInput, ParseResult, SearchContext, + TagResult, } from "./query.types"; export abstract class FilterService { abstract readonly parse: OperatorFunction; abstract readonly filter: OperatorFunction<[string | ParseResult, SearchContext], FilterResult>; + /** + * Tags ciphers based on the provided queries. + * + * The queries are in the form of a record, where the key is the name of the tag and the value is either a string or a ParseResult query to perform. + */ + abstract readonly tag: OperatorFunction< + [[string, string][] | [string, ParseResult][], SearchContext], + TagResult + >; abstract context$(context: ObservableSearchContextInput): Observable; } @@ -60,25 +71,25 @@ export class DefaultFilterService implements FilterService { processInstructions: null, }); } else { - return query.pipe( - map((query: string) => { - try { - return { - isError: false as const, - processInstructions: parseQuery(query, this.logService), - }; - } catch { - return { - isError: true as const, - processInstructions: null, - }; - } - }), - ); + return query.pipe(map((query: string) => this.parseQueryString(query))); } }; } + private parseQueryString(query: string): ParseResult { + try { + return { + isError: false as const, + processInstructions: parseQuery(query, this.logService), + }; + } catch { + return { + isError: true as const, + processInstructions: null, + }; + } + } + get filter(): OperatorFunction<[string | ParseResult, SearchContext], FilterResult> { return pipe( switchMap(([queryOrParsedQuery, context]) => { @@ -106,6 +117,66 @@ export class DefaultFilterService implements FilterService { ); } + get tag(): OperatorFunction< + [[string, string][] | [string, ParseResult][], SearchContext], + TagResult + > { + return pipe( + switchMap(([queryOrParsedQueries, context]) => { + const parsedQueries: [string, ParseResult][] = []; + queryOrParsedQueries.forEach(([name, q]) => { + if (q == null || typeof q === "string") { + // it's a string that needs parsing + parsedQueries.push([name, this.parseQueryString(q as string)]); + } else { + // It's a parsed query + parsedQueries.push([name, q]); + } + }); + return combineLatest([of(parsedQueries), of(context)]); + }), + map(([parseResults, context]) => { + if (parseResults.length === 0) { + return { + ciphers: context.ciphers as FilterTagged[], + isError: false, + }; + } + // Reduce the parse results to a single result + return parseResults.reduce( + (acc, [name, parseResult]) => { + // cannot process if any parse result is error + if (parseResult.isError) { + return { + ciphers: acc.ciphers, + isError: true, + }; + } + // Identify the ciphers to tag for this query + const hitCipherIds = parseResult.processInstructions + .filter(context) + .ciphers.map((c) => c.id); + + return { + // tag ciphers for this query + ciphers: acc.ciphers.map((c) => { + const tagged = c as FilterTagged; + tagged.tags ??= []; + if (hitCipherIds.includes(c.id)) { + tagged.tags.push(name); + } + return tagged; + }), + isError: acc.isError, + }; + }, + // Initial value of the accumulator + { ciphers: context.ciphers, isError: false } as TagResult, + ); + }), + ); + } + private buildIndex(ciphers: CipherView[]) { const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); diff --git a/libs/common/src/vault/search/query.types.ts b/libs/common/src/vault/search/query.types.ts index a98c539ab2c..0887b880d4c 100644 --- a/libs/common/src/vault/search/query.types.ts +++ b/libs/common/src/vault/search/query.types.ts @@ -31,6 +31,13 @@ export type FilterResult = isError: true; }; +export type TagResult = { + ciphers: FilterTagged[]; + isError: boolean; +}; + +export type FilterTagged = T & { tags: string[] }; + export type ProcessInstructions = { filter: (context: SearchContext) => SearchContext; ast: Search; From c05558c4763662b43fc2c76e743da266c8a4680e Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 20 Mar 2025 09:25:05 -0700 Subject: [PATCH 2/2] Add has field filter operator --- libs/common/src/vault/search/ast.ts | 10 ++++++++++ .../src/vault/search/bitwarden-query-grammar.ne | 2 ++ .../src/vault/search/bitwarden-query-grammar.ts | 13 +++++++++++++ libs/common/src/vault/search/parse.ts | 12 ++++++++++++ 4 files changed, 37 insertions(+) diff --git a/libs/common/src/vault/search/ast.ts b/libs/common/src/vault/search/ast.ts index da35c94456e..62454d5b92d 100644 --- a/libs/common/src/vault/search/ast.ts +++ b/libs/common/src/vault/search/ast.ts @@ -6,6 +6,7 @@ export const AstNodeTypeNames = [ "or", "term", "fieldTerm", + "hasField", "hasAttachment", "hasUri", "hasFolder", @@ -30,6 +31,7 @@ export type AstNode = | Or | Term | FieldTerm + | HasField | HasAttachment | HasUri | HasFolder @@ -118,6 +120,14 @@ export function isFieldTerm(x: AstNode): x is FieldTerm { return x.type === "fieldTerm"; } +export type HasField = AstNodeBase & { + type: "hasField"; + field: string; +}; +export function isHasField(x: AstNode): x is HasField { + return x.type === "hasField"; +} + export type HasAttachment = AstNodeBase & { type: "hasAttachment"; }; diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ne b/libs/common/src/vault/search/bitwarden-query-grammar.ne index 395b3b601b8..173206fe503 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ne +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ne @@ -47,6 +47,8 @@ TERM -> %string {% function(d) { const start = d[0].offset; const end = d[0].offset + d[0].value.length; return { type: 'term', value: d[0].value, start, end, length: d[0].value.length } } %} # specified field search term | %func_field %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'fieldTerm', field: d[1].value, term: d[3].value, start, end, length: end - start + 1 } } %} + # Has specified field as non-null + | %func_has %func_field %string {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'hasField', field: d[2].value, 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', start, end: d[0].offset + length, length } } %} # only items with URIs diff --git a/libs/common/src/vault/search/bitwarden-query-grammar.ts b/libs/common/src/vault/search/bitwarden-query-grammar.ts index 04892fbd1d3..7a89c32c267 100644 --- a/libs/common/src/vault/search/bitwarden-query-grammar.ts +++ b/libs/common/src/vault/search/bitwarden-query-grammar.ts @@ -185,6 +185,19 @@ const grammar: Grammar = { }; }, }, + { + name: "TERM", + symbols: [ + lexer.has("func_has") ? { type: "func_has" } : func_has, + lexer.has("func_field") ? { type: "func_field" } : func_field, + lexer.has("string") ? { type: "string" } : string, + ], + postprocess: function (d) { + const start = d[0].offset; + const end = d[2].offset + d[2].value.length; + return { type: "hasField", field: d[2].value, start, end, length: end - start + 1 }; + }, + }, { name: "TERM", symbols: [lexer.has("func_has") ? { type: "func_has" } : func_has, { literal: "attachment" }], diff --git a/libs/common/src/vault/search/parse.ts b/libs/common/src/vault/search/parse.ts index 4353361f3e1..ca510ca9c45 100644 --- a/libs/common/src/vault/search/parse.ts +++ b/libs/common/src/vault/search/parse.ts @@ -14,6 +14,7 @@ import { isAnd, isFieldTerm, isHasAttachment, + isHasField, isHasFolder, isHasUri, isInCollection, @@ -118,6 +119,17 @@ function handleNode(node: AstNode): { filter: (context: SearchContext) => Search }; }, }; + } else if (isHasField(node)) { + const fieldTest = fieldNameToRegexTest(node.field); + return { + filter: (context) => ({ + ...context, + ciphers: context.ciphers.filter((cipher) => { + const foundValues = fieldValues(cipher, fieldTest); + return foundValues.fields.some((foundValue) => !!foundValue.value); + }), + }), + }; } else if (isHasAttachment(node)) { return { filter: (context) => ({