Skip to content

Commit df6447e

Browse files
authored
feat(quick_search): also allow for the equals operator in note title's quick search (#6769)
2 parents 24fd898 + b0b788b commit df6447e

File tree

7 files changed

+168
-7
lines changed

7 files changed

+168
-7
lines changed

apps/server/src/services/search/services/lex.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,34 @@ describe("Lexer fulltext", () => {
5959
it("escaping special characters", () => {
6060
expect(lex("hello \\#\\~\\'").fulltextTokens.map((t) => t.token)).toEqual(["hello", "#~'"]);
6161
});
62+
63+
it("recognizes leading = operator for exact match", () => {
64+
const result1 = lex("=example");
65+
expect(result1.fulltextTokens.map((t) => t.token)).toEqual(["example"]);
66+
expect(result1.leadingOperator).toBe("=");
67+
68+
const result2 = lex("=hello world");
69+
expect(result2.fulltextTokens.map((t) => t.token)).toEqual(["hello", "world"]);
70+
expect(result2.leadingOperator).toBe("=");
71+
72+
const result3 = lex("='hello world'");
73+
expect(result3.fulltextTokens.map((t) => t.token)).toEqual(["hello world"]);
74+
expect(result3.leadingOperator).toBe("=");
75+
});
76+
77+
it("doesn't treat = as leading operator in other contexts", () => {
78+
const result1 = lex("==example");
79+
expect(result1.fulltextTokens.map((t) => t.token)).toEqual(["==example"]);
80+
expect(result1.leadingOperator).toBe("");
81+
82+
const result2 = lex("= example");
83+
expect(result2.fulltextTokens.map((t) => t.token)).toEqual(["=", "example"]);
84+
expect(result2.leadingOperator).toBe("");
85+
86+
const result3 = lex("example");
87+
expect(result3.fulltextTokens.map((t) => t.token)).toEqual(["example"]);
88+
expect(result3.leadingOperator).toBe("");
89+
});
6290
});
6391

6492
describe("Lexer expression", () => {

apps/server/src/services/search/services/lex.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@ function lex(str: string) {
1010
let quotes: boolean | string = false; // otherwise contains used quote - ', " or `
1111
let fulltextEnded = false;
1212
let currentWord = "";
13+
let leadingOperator = "";
1314

1415
function isSymbolAnOperator(chr: string) {
1516
return ["=", "*", ">", "<", "!", "-", "+", "%", ","].includes(chr);
1617
}
18+
19+
// Check if the string starts with an exact match operator
20+
// This allows users to use "=searchterm" for exact matching
21+
if (str.startsWith("=") && str.length > 1 && str[1] !== "=" && str[1] !== " ") {
22+
leadingOperator = "=";
23+
str = str.substring(1); // Remove the leading operator from the string
24+
}
1725

1826
function isPreviousSymbolAnOperator() {
1927
if (currentWord.length === 0) {
@@ -128,7 +136,8 @@ function lex(str: string) {
128136
return {
129137
fulltextQuery,
130138
fulltextTokens,
131-
expressionTokens
139+
expressionTokens,
140+
leadingOperator
132141
};
133142
}
134143

apps/server/src/services/search/services/parse.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type SearchContext from "../search_context.js";
2424
import type { TokenData, TokenStructure } from "./types.js";
2525
import type Expression from "../expressions/expression.js";
2626

27-
function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
27+
function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leadingOperator?: string) {
2828
const tokens: string[] = _tokens.map((t) => removeDiacritic(t.token));
2929

3030
searchContext.highlightedTokens.push(...tokens);
@@ -33,8 +33,19 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext) {
3333
return null;
3434
}
3535

36+
// If user specified "=" at the beginning, they want exact match
37+
const operator = leadingOperator === "=" ? "=" : "*=*";
38+
3639
if (!searchContext.fastSearch) {
37-
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp("*=*", { tokens, flatText: true })]);
40+
// For exact match with "=", we need different behavior
41+
if (leadingOperator === "=" && tokens.length === 1) {
42+
// Exact match on title OR exact match on content
43+
return new OrExp([
44+
new PropertyComparisonExp(searchContext, "title", "=", tokens[0]),
45+
new NoteContentFulltextExp("=", { tokens, flatText: false })
46+
]);
47+
}
48+
return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]);
3849
} else {
3950
return new NoteFlatTextExp(tokens);
4051
}
@@ -428,9 +439,10 @@ export interface ParseOpts {
428439
expressionTokens: TokenStructure;
429440
searchContext: SearchContext;
430441
originalQuery?: string;
442+
leadingOperator?: string;
431443
}
432444

433-
function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) {
445+
function parse({ fulltextTokens, expressionTokens, searchContext, leadingOperator }: ParseOpts) {
434446
let expression: Expression | undefined | null;
435447

436448
try {
@@ -444,7 +456,7 @@ function parse({ fulltextTokens, expressionTokens, searchContext }: ParseOpts) {
444456
let exp = AndExp.of([
445457
searchContext.includeArchivedNotes ? null : new PropertyComparisonExp(searchContext, "isarchived", "=", "false"),
446458
getAncestorExp(searchContext),
447-
getFulltext(fulltextTokens, searchContext),
459+
getFulltext(fulltextTokens, searchContext, leadingOperator),
448460
expression
449461
]);
450462

apps/server/src/services/search/services/search.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,28 @@ describe("Search", () => {
234234
expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy();
235235
});
236236

237+
it("leading = operator for exact match", () => {
238+
rootNote
239+
.child(note("Example Note").label("type", "document"))
240+
.child(note("Examples of Usage").label("type", "tutorial"))
241+
.child(note("Sample").label("type", "example"));
242+
243+
const searchContext = new SearchContext();
244+
245+
// Using leading = for exact title match
246+
let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext);
247+
expect(searchResults.length).toEqual(1);
248+
expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy();
249+
250+
// Without =, it should find all notes containing "example"
251+
searchResults = searchService.findResultsWithQuery("example", searchContext);
252+
expect(searchResults.length).toEqual(3);
253+
254+
// = operator should not match partial words
255+
searchResults = searchService.findResultsWithQuery("=Example", searchContext);
256+
expect(searchResults.length).toEqual(0);
257+
});
258+
237259
it("fuzzy attribute search", () => {
238260
rootNote.child(note("Europe")
239261
.label("country", "", true)

apps/server/src/services/search/services/search.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S
367367
}
368368

369369
function parseQueryToExpression(query: string, searchContext: SearchContext) {
370-
const { fulltextQuery, fulltextTokens, expressionTokens } = lex(query);
370+
const { fulltextQuery, fulltextTokens, expressionTokens, leadingOperator } = lex(query);
371371
searchContext.fulltextQuery = fulltextQuery;
372372

373373
let structuredExpressionTokens: TokenStructure;
@@ -383,7 +383,8 @@ function parseQueryToExpression(query: string, searchContext: SearchContext) {
383383
fulltextTokens,
384384
expressionTokens: structuredExpressionTokens,
385385
searchContext,
386-
originalQuery: query
386+
originalQuery: query,
387+
leadingOperator
387388
});
388389

389390
if (searchContext.debug) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import lex from "./apps/server/dist/services/search/services/lex.js";
2+
import parse from "./apps/server/dist/services/search/services/parse.js";
3+
import SearchContext from "./apps/server/dist/services/search/search_context.js";
4+
5+
// Test the integration of the lexer and parser
6+
const testCases = [
7+
"=example",
8+
"example",
9+
"=hello world"
10+
];
11+
12+
for (const query of testCases) {
13+
console.log(`\n=== Testing: "${query}" ===`);
14+
15+
const lexResult = lex(query);
16+
console.log("Lex result:");
17+
console.log(" Fulltext tokens:", lexResult.fulltextTokens.map(t => t.token));
18+
console.log(" Leading operator:", lexResult.leadingOperator || "(none)");
19+
20+
const searchContext = new SearchContext.default({ fastSearch: false });
21+
22+
try {
23+
const expression = parse.default({
24+
fulltextTokens: lexResult.fulltextTokens,
25+
expressionTokens: [],
26+
searchContext,
27+
originalQuery: query,
28+
leadingOperator: lexResult.leadingOperator
29+
});
30+
31+
console.log("Parse result: Success");
32+
console.log(" Expression type:", expression.constructor.name);
33+
} catch (e) {
34+
console.log("Parse result: Error -", e.message);
35+
}
36+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Quick Search - Exact Match Operator
2+
3+
## Overview
4+
5+
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.
6+
7+
## Usage
8+
9+
To use exact match in Quick Search:
10+
11+
1. Start your search query with the `=` operator
12+
2. Follow it immediately with your search term (no space after `=`)
13+
14+
### Examples
15+
16+
- `=example` - Finds notes with title exactly "example" or content exactly "example"
17+
- `=Project Plan` - Finds notes with title exactly "Project Plan" or content exactly "Project Plan"
18+
- `='hello world'` - Use quotes for multi-word exact matches
19+
20+
### Comparison with Regular Search
21+
22+
| Query | Behavior |
23+
|-------|----------|
24+
| `example` | Finds all notes containing "example" anywhere in title or content |
25+
| `=example` | Finds only notes where the title equals "example" or content equals "example" exactly |
26+
27+
## Technical Details
28+
29+
When you use the `=` operator:
30+
- The search performs an exact match on note titles
31+
- For note content, it looks for exact matches of the entire content
32+
- Partial word matches are excluded
33+
- The search is case-insensitive
34+
35+
## Limitations
36+
37+
- The `=` operator must be at the very beginning of the search query
38+
- Spaces after `=` will treat it as a regular search
39+
- Multiple `=` operators (like `==example`) are treated as regular text search
40+
41+
## Use Cases
42+
43+
This feature is particularly useful when:
44+
- You know the exact title of a note
45+
- You want to find notes with specific, complete content
46+
- You need to distinguish between notes with similar but not identical titles
47+
- You want to avoid false positives from partial matches
48+
49+
## Related Features
50+
51+
- For more complex exact matching queries, use the full [Search](Search.md) functionality
52+
- For fuzzy matching (finding results despite typos), use the `~=` operator in the full search
53+
- For partial matches with wildcards, use operators like `*=*`, `=*`, or `*=` in the full search

0 commit comments

Comments
 (0)