mirror of
https://github.com/zadam/trilium.git
synced 2025-10-19 14:49:01 +02:00
feat(quick_search): also allow for the equals operator in note title's quick search (#6769)
This commit is contained in:
commit
df6447e3ad
@ -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", () => {
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
]);
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
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
|
Loading…
x
Reference in New Issue
Block a user