1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'poc/search-query-language' of https://github.com/bitwarden/clients into poc/search-query-language

This commit is contained in:
Justin Baur
2025-03-20 12:41:42 -04:00
7 changed files with 161 additions and 19 deletions

View File

@@ -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 }),
);

View File

@@ -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
@@ -122,6 +124,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";
};

View File

@@ -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

View File

@@ -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" }],

View File

@@ -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<string, ParseResult>;
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<SearchContext>;
}
@@ -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<CipherView>[],
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<CipherView>;
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);

View File

@@ -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) => ({

View File

@@ -31,6 +31,13 @@ export type FilterResult =
isError: true;
};
export type TagResult = {
ciphers: FilterTagged<CipherView>[];
isError: boolean;
};
export type FilterTagged<T> = T & { tags: string[] };
export type ProcessInstructions = {
filter: (context: SearchContext) => SearchContext;
ast: Search;