feat(quick_search): also allow for the equals operator in note title's quick search (#6769)

This commit is contained in:
Elian Doran
2025-08-25 20:49:45 +03:00
committed by GitHub
7 changed files with 168 additions and 7 deletions

View File

@@ -59,6 +59,34 @@ describe("Lexer fulltext", () => {
it("escaping special characters", () => {
expect(lex("hello \\#\\~\\'").fulltextTokens.map((t) => t.token)).toEqual(["hello", "#~'"]);
});
it("recognizes leading = operator for exact match", () => {
const result1 = lex("=example");
expect(result1.fulltextTokens.map((t) => t.token)).toEqual(["example"]);
expect(result1.leadingOperator).toBe("=");
const result2 = lex("=hello world");
expect(result2.fulltextTokens.map((t) => t.token)).toEqual(["hello", "world"]);
expect(result2.leadingOperator).toBe("=");
const result3 = lex("='hello world'");
expect(result3.fulltextTokens.map((t) => t.token)).toEqual(["hello world"]);
expect(result3.leadingOperator).toBe("=");
});
it("doesn't treat = as leading operator in other contexts", () => {
const result1 = lex("==example");
expect(result1.fulltextTokens.map((t) => t.token)).toEqual(["==example"]);
expect(result1.leadingOperator).toBe("");
const result2 = lex("= example");
expect(result2.fulltextTokens.map((t) => t.token)).toEqual(["=", "example"]);
expect(result2.leadingOperator).toBe("");
const result3 = lex("example");
expect(result3.fulltextTokens.map((t) => t.token)).toEqual(["example"]);
expect(result3.leadingOperator).toBe("");
});
});
describe("Lexer expression", () => {

View File

@@ -10,10 +10,18 @@ function lex(str: string) {
let quotes: boolean | string = false; // otherwise contains used quote - ', " or `
let fulltextEnded = false;
let currentWord = "";
let leadingOperator = "";
function isSymbolAnOperator(chr: string) {
return ["=", "*", ">", "<", "!", "-", "+", "%", ","].includes(chr);
}
// Check if the string starts with an exact match operator
// This allows users to use "=searchterm" for exact matching
if (str.startsWith("=") && str.length > 1 && str[1] !== "=" && str[1] !== " ") {
leadingOperator = "=";
str = str.substring(1); // Remove the leading operator from the string
}
function isPreviousSymbolAnOperator() {
if (currentWord.length === 0) {
@@ -128,7 +136,8 @@ function lex(str: string) {
return {
fulltextQuery,
fulltextTokens,
expressionTokens
expressionTokens,
leadingOperator
};
}

View File

@@ -24,7 +24,7 @@ import type SearchContext from "../search_context.js";
import type { TokenData, TokenStructure } from "./types.js";
import type Expression from "../expressions/expression.js";
function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leadingOperator?: string) {
const tokens: string[] = _tokens.map((t) => removeDiacritic(t.token));
searchContext.highlightedTokens.push(...tokens);
@@ -33,8 +33,19 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
return null;
}
// If user specified "=" at the beginning, they want exact match
const operator = leadingOperator === "=" ? "=" : "*=*";
if (!searchContext.fastSearch) {
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp("*=*", { tokens, flatText: true })]);
// For exact match with "=", we need different behavior
if (leadingOperator === "=" && tokens.length === 1) {
// Exact match on title OR exact match on content
return new OrExp([
new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
new NoteContentFulltextExp("=", { tokens, flatText: false })
]);
}
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]);
} else {
return new NoteFlatTextExp(tokens);
}
@@ -428,9 +439,10 @@ export interface ParseOpts {
expressionTokens: TokenStructure;
searchContext: SearchContext;
originalQuery?: string;
leadingOperator?: string;
}
function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) {
function parse({ fulltextTokens, expressionTokens, searchContext, leadingOperator }: ParseOpts) {
let expression: Expression | undefined | null;
try {
@@ -444,7 +456,7 @@ function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) {
let exp = AndExp.of([
searchContext.includeArchivedNotes ? null : new PropertyComparisonExp(searchContext, "isarchived", "=", "false"),
getAncestorExp(searchContext),
getFulltext(fulltextTokens, searchContext),
getFulltext(fulltextTokens, searchContext, leadingOperator),
expression
]);

View File

@@ -234,6 +234,28 @@ describe("Search", () => {
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
});
it("leading = operator for exact match", () => {
rootNote
.child(note("Example Note").label("type", "document"))
.child(note("Examples of Usage").label("type", "tutorial"))
.child(note("Sample").label("type", "example"));
const searchContext = new SearchContext();
// Using leading = for exact title match
let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext);
expect(searchResults.length).toEqual(1);
expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy();
// Without =, it should find all notes containing "example"
searchResults = searchService.findResultsWithQuery("example", searchContext);
expect(searchResults.length).toEqual(3);
// = operator should not match partial words
searchResults = searchService.findResultsWithQuery("=Example", searchContext);
expect(searchResults.length).toEqual(0);
});
it("fuzzy attribute search", () => {
rootNote.child(note("Europe")
.label("country", "", true)

View File

@@ -367,7 +367,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S
}
function parseQueryToExpression(query: string, searchContext: SearchContext) {
const { fulltextQuery, fulltextTokens, expressionTokens } = lex(query);
const { fulltextQuery, fulltextTokens, expressionTokens, leadingOperator } = lex(query);
searchContext.fulltextQuery = fulltextQuery;
let structuredExpressionTokens: TokenStructure;
@@ -383,7 +383,8 @@ function parseQueryToExpression(query: string, searchContext: SearchContext) {
fulltextTokens,
expressionTokens: structuredExpressionTokens,
searchContext,
originalQuery: query
originalQuery: query,
leadingOperator
});
if (searchContext.debug) {