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

This commit is contained in:
perf3ct 2025-08-24 18:53:05 +00:00
parent c97c66ed8a
commit 93c5413790
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
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,11 +10,19 @@ 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) {
return false;
@ -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) {

View File

@ -0,0 +1,36 @@
import lex from "./apps/server/dist/services/search/services/lex.js";
import parse from "./apps/server/dist/services/search/services/parse.js";
import SearchContext from "./apps/server/dist/services/search/search_context.js";
// Test the integration of the lexer and parser
const testCases = [
"=example",
"example",
"=hello world"
];
for (const query of testCases) {
console.log(`\n=== Testing: "${query}" ===`);
const lexResult = lex(query);
console.log("Lex result:");
console.log(" Fulltext tokens:", lexResult.fulltextTokens.map(t => t.token));
console.log(" Leading operator:", lexResult.leadingOperator || "(none)");
const searchContext = new SearchContext.default({ fastSearch: false });
try {
const expression = parse.default({
fulltextTokens: lexResult.fulltextTokens,
expressionTokens: [],
searchContext,
originalQuery: query,
leadingOperator: lexResult.leadingOperator
});
console.log("Parse result: Success");
console.log(" Expression type:", expression.constructor.name);
} catch (e) {
console.log("Parse result: Error -", e.message);
}
}

View File

@ -0,0 +1,53 @@
# Quick Search - Exact Match Operator
## Overview
Quick Search now supports the exact match operator (`=`) at the beginning of your search query. This allows you to search for notes where the title or content exactly matches your search term, rather than just containing it.
## Usage
To use exact match in Quick Search:
1. Start your search query with the `=` operator
2. Follow it immediately with your search term (no space after `=`)
### Examples
- `=example` - Finds notes with title exactly "example" or content exactly "example"
- `=Project Plan` - Finds notes with title exactly "Project Plan" or content exactly "Project Plan"
- `='hello world'` - Use quotes for multi-word exact matches
### Comparison with Regular Search
| Query | Behavior |
|-------|----------|
| `example` | Finds all notes containing "example" anywhere in title or content |
| `=example` | Finds only notes where the title equals "example" or content equals "example" exactly |
## Technical Details
When you use the `=` operator:
- The search performs an exact match on note titles
- For note content, it looks for exact matches of the entire content
- Partial word matches are excluded
- The search is case-insensitive
## Limitations
- The `=` operator must be at the very beginning of the search query
- Spaces after `=` will treat it as a regular search
- Multiple `=` operators (like `==example`) are treated as regular text search
## Use Cases
This feature is particularly useful when:
- You know the exact title of a note
- You want to find notes with specific, complete content
- You need to distinguish between notes with similar but not identical titles
- You want to avoid false positives from partial matches
## Related Features
- For more complex exact matching queries, use the full [Search](Search.md) functionality
- For fuzzy matching (finding results despite typos), use the `~=` operator in the full search
- For partial matches with wildcards, use operators like `*=*`, `=*`, or `*=` in the full search