diff --git a/apps/server/src/services/search/services/lex.spec.ts b/apps/server/src/services/search/services/lex.spec.ts index 06680af12..668522b18 100644 --- a/apps/server/src/services/search/services/lex.spec.ts +++ b/apps/server/src/services/search/services/lex.spec.ts @@ -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", () => { diff --git a/apps/server/src/services/search/services/lex.ts b/apps/server/src/services/search/services/lex.ts index 5b80691d9..2fcd99ca2 100644 --- a/apps/server/src/services/search/services/lex.ts +++ b/apps/server/src/services/search/services/lex.ts @@ -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 }; } diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index e96ca3896..b537ee562 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -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 ]); diff --git a/apps/server/src/services/search/services/search.spec.ts b/apps/server/src/services/search/services/search.spec.ts index 773525caa..d448a04b0 100644 --- a/apps/server/src/services/search/services/search.spec.ts +++ b/apps/server/src/services/search/services/search.spec.ts @@ -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) diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 0d3592455..22dbe6d9f 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -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) { diff --git a/apps/server/test_search_integration.js b/apps/server/test_search_integration.js new file mode 100644 index 000000000..bd23dc8c6 --- /dev/null +++ b/apps/server/test_search_integration.js @@ -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); + } +} diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search - Exact Match.md b/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search - Exact Match.md new file mode 100644 index 000000000..186709b07 --- /dev/null +++ b/docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search - Exact Match.md @@ -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 \ No newline at end of file