mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	feat(quick_search): also allow for the equals operator in note title's quick search (#6769)
This commit is contained in:
		| @@ -59,6 +59,34 @@ describe("Lexer fulltext", () => { | |||||||
|     it("escaping special characters", () => { |     it("escaping special characters", () => { | ||||||
|         expect(lex("hello \\#\\~\\'").fulltextTokens.map((t) => t.token)).toEqual(["hello", "#~'"]); |         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", () => { | describe("Lexer expression", () => { | ||||||
|   | |||||||
| @@ -10,11 +10,19 @@ function lex(str: string) { | |||||||
|     let quotes: boolean | string = false; // otherwise contains used quote - ', " or ` |     let quotes: boolean | string = false; // otherwise contains used quote - ', " or ` | ||||||
|     let fulltextEnded = false; |     let fulltextEnded = false; | ||||||
|     let currentWord = ""; |     let currentWord = ""; | ||||||
|  |     let leadingOperator = ""; | ||||||
|  |  | ||||||
|     function isSymbolAnOperator(chr: string) { |     function isSymbolAnOperator(chr: string) { | ||||||
|         return ["=", "*", ">", "<", "!", "-", "+", "%", ","].includes(chr); |         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() { |     function isPreviousSymbolAnOperator() { | ||||||
|         if (currentWord.length === 0) { |         if (currentWord.length === 0) { | ||||||
|             return false; |             return false; | ||||||
| @@ -128,7 +136,8 @@ function lex(str: string) { | |||||||
|     return { |     return { | ||||||
|         fulltextQuery, |         fulltextQuery, | ||||||
|         fulltextTokens, |         fulltextTokens, | ||||||
|         expressionTokens |         expressionTokens, | ||||||
|  |         leadingOperator | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ import type SearchContext from "../search_context.js"; | |||||||
| import type { TokenData, TokenStructure } from "./types.js"; | import type { TokenData, TokenStructure } from "./types.js"; | ||||||
| import type Expression from "../expressions/expression.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)); |     const tokens: string[] = _tokens.map((t) => removeDiacritic(t.token)); | ||||||
|  |  | ||||||
|     searchContext.highlightedTokens.push(...tokens); |     searchContext.highlightedTokens.push(...tokens); | ||||||
| @@ -33,8 +33,19 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) { | |||||||
|         return null; |         return null; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // If user specified "=" at the beginning, they want exact match | ||||||
|  |     const operator = leadingOperator === "=" ? "=" : "*=*"; | ||||||
|  |  | ||||||
|     if (!searchContext.fastSearch) { |     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 { |     } else { | ||||||
|         return new NoteFlatTextExp(tokens); |         return new NoteFlatTextExp(tokens); | ||||||
|     } |     } | ||||||
| @@ -428,9 +439,10 @@ export interface ParseOpts { | |||||||
|     expressionTokens: TokenStructure; |     expressionTokens: TokenStructure; | ||||||
|     searchContext: SearchContext; |     searchContext: SearchContext; | ||||||
|     originalQuery?: string; |     originalQuery?: string; | ||||||
|  |     leadingOperator?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) { | function parse({ fulltextTokens, expressionTokens, searchContext, leadingOperator }: ParseOpts) { | ||||||
|     let expression: Expression | undefined | null; |     let expression: Expression | undefined | null; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
| @@ -444,7 +456,7 @@ function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) { | |||||||
|     let exp = AndExp.of([ |     let exp = AndExp.of([ | ||||||
|         searchContext.includeArchivedNotes ? null : new PropertyComparisonExp(searchContext, "isarchived", "=", "false"), |         searchContext.includeArchivedNotes ? null : new PropertyComparisonExp(searchContext, "isarchived", "=", "false"), | ||||||
|         getAncestorExp(searchContext), |         getAncestorExp(searchContext), | ||||||
|         getFulltext(fulltextTokens, searchContext), |         getFulltext(fulltextTokens, searchContext, leadingOperator), | ||||||
|         expression |         expression | ||||||
|     ]); |     ]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -234,6 +234,28 @@ describe("Search", () => { | |||||||
|         expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); |         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", () => { |     it("fuzzy attribute search", () => { | ||||||
|         rootNote.child(note("Europe") |         rootNote.child(note("Europe") | ||||||
|                 .label("country", "", true) |                 .label("country", "", true) | ||||||
|   | |||||||
| @@ -367,7 +367,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S | |||||||
| } | } | ||||||
|  |  | ||||||
| function parseQueryToExpression(query: string, searchContext: SearchContext) { | function parseQueryToExpression(query: string, searchContext: SearchContext) { | ||||||
|     const { fulltextQuery, fulltextTokens, expressionTokens } = lex(query); |     const { fulltextQuery, fulltextTokens, expressionTokens, leadingOperator } = lex(query); | ||||||
|     searchContext.fulltextQuery = fulltextQuery; |     searchContext.fulltextQuery = fulltextQuery; | ||||||
|  |  | ||||||
|     let structuredExpressionTokens: TokenStructure; |     let structuredExpressionTokens: TokenStructure; | ||||||
| @@ -383,7 +383,8 @@ function parseQueryToExpression(query: string, searchContext: SearchContext) { | |||||||
|         fulltextTokens, |         fulltextTokens, | ||||||
|         expressionTokens: structuredExpressionTokens, |         expressionTokens: structuredExpressionTokens, | ||||||
|         searchContext, |         searchContext, | ||||||
|         originalQuery: query |         originalQuery: query, | ||||||
|  |         leadingOperator | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     if (searchContext.debug) { |     if (searchContext.debug) { | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								apps/server/test_search_integration.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/server/test_search_integration.js
									
									
									
									
									
										Normal 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); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										53
									
								
								docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search - Exact Match.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search - Exact Match.md
									
									
									
									
										vendored
									
									
										Normal 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 | ||||||
		Reference in New Issue
	
	Block a user