mirror of
https://github.com/zadam/trilium.git
synced 2025-10-19 22:58:52 +02:00
feat(quick_search): also allow for the equals operator in note title's quick search
This commit is contained in:
parent
c97c66ed8a
commit
93c5413790
@ -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
|
Loading…
x
Reference in New Issue
Block a user