Skip to content

Commit 6e37c9e

Browse files
authored
feat(search): improve search weights and operators (#6536)
2 parents 963f458 + 415bbc3 commit 6e37c9e

File tree

18 files changed

+1668
-133
lines changed

18 files changed

+1668
-133
lines changed

apps/client/src/stylesheets/style.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,6 +2255,13 @@ footer.webview-footer button {
22552255
padding: 1px 10px 1px 10px;
22562256
}
22572257

2258+
/* Search result highlighting */
2259+
.search-result-title b,
2260+
.search-result-content b {
2261+
font-weight: 900;
2262+
color: var(--admonition-warning-accent-color);
2263+
}
2264+
22582265
/* Customized icons */
22592266

22602267
.bx-tn-toc::before {

apps/client/src/widgets/quick_search.ts

Lines changed: 195 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,52 @@ const TPL = /*html*/`
2323
2424
.quick-search .dropdown-menu {
2525
max-height: 600px;
26-
max-width: 400px;
26+
max-width: 600px;
2727
overflow-y: auto;
2828
overflow-x: hidden;
2929
text-overflow: ellipsis;
3030
box-shadow: -30px 50px 93px -50px black;
3131
}
32+
33+
.quick-search .dropdown-item {
34+
white-space: normal;
35+
padding: 12px 16px;
36+
line-height: 1.4;
37+
position: relative;
38+
}
39+
40+
.quick-search .dropdown-item:not(:last-child)::after {
41+
content: '';
42+
position: absolute;
43+
bottom: 0;
44+
left: 50%;
45+
transform: translateX(-50%);
46+
width: 80%;
47+
height: 2px;
48+
background: var(--main-border-color);
49+
border-radius: 1px;
50+
opacity: 0.4;
51+
}
52+
53+
.quick-search .dropdown-item:last-child::after {
54+
display: none;
55+
}
56+
57+
.quick-search .dropdown-item.disabled::after {
58+
display: none;
59+
}
60+
61+
.quick-search .dropdown-item.show-in-full-search::after {
62+
display: none;
63+
}
64+
65+
.quick-search .dropdown-item:hover {
66+
background-color: #f8f9fa;
67+
}
68+
69+
.quick-search .dropdown-divider {
70+
margin: 0;
71+
}
3272
</style>
3373
3474
<div class="input-group-prepend">
@@ -40,11 +80,21 @@ const TPL = /*html*/`
4080
<input type="text" class="form-control form-control-sm search-string" placeholder="${t("quick-search.placeholder")}">
4181
</div>`;
4282

43-
const MAX_DISPLAYED_NOTES = 15;
83+
const INITIAL_DISPLAYED_NOTES = 15;
84+
const LOAD_MORE_BATCH_SIZE = 10;
4485

4586
// TODO: Deduplicate with server.
4687
interface QuickSearchResponse {
4788
searchResultNoteIds: string[];
89+
searchResults?: Array<{
90+
notePath: string;
91+
noteTitle: string;
92+
notePathTitle: string;
93+
highlightedNotePathTitle: string;
94+
contentSnippet?: string;
95+
highlightedContentSnippet?: string;
96+
icon: string;
97+
}>;
4898
error: string;
4999
}
50100

@@ -53,6 +103,12 @@ export default class QuickSearchWidget extends BasicWidget {
53103
private dropdown!: bootstrap.Dropdown;
54104
private $searchString!: JQuery<HTMLElement>;
55105
private $dropdownMenu!: JQuery<HTMLElement>;
106+
107+
// State for infinite scrolling
108+
private allSearchResults: Array<any> = [];
109+
private allSearchResultNoteIds: string[] = [];
110+
private currentDisplayedCount: number = 0;
111+
private isLoadingMore: boolean = false;
56112

57113
doRender() {
58114
this.$widget = $(TPL);
@@ -68,6 +124,11 @@ export default class QuickSearchWidget extends BasicWidget {
68124
});
69125

70126
this.$widget.find(".input-group-prepend").on("shown.bs.dropdown", () => this.search());
127+
128+
// Add scroll event listener for infinite scrolling
129+
this.$dropdownMenu.on("scroll", () => {
130+
this.handleScroll();
131+
});
71132

72133
if (utils.isMobile()) {
73134
this.$searchString.keydown((e) => {
@@ -112,10 +173,16 @@ export default class QuickSearchWidget extends BasicWidget {
112173
return;
113174
}
114175

176+
// Reset state for new search
177+
this.allSearchResults = [];
178+
this.allSearchResultNoteIds = [];
179+
this.currentDisplayedCount = 0;
180+
this.isLoadingMore = false;
181+
115182
this.$dropdownMenu.empty();
116183
this.$dropdownMenu.append(`<span class="dropdown-item disabled"><span class="bx bx-loader bx-spin"></span>${t("quick-search.searching")}</span>`);
117184

118-
const { searchResultNoteIds, error } = await server.get<QuickSearchResponse>(`quick-search/${encodeURIComponent(searchString)}`);
185+
const { searchResultNoteIds, searchResults, error } = await server.get<QuickSearchResponse>(`quick-search/${encodeURIComponent(searchString)}`);
119186

120187
if (error) {
121188
let tooltip = new Tooltip(this.$searchString[0], {
@@ -129,47 +196,148 @@ export default class QuickSearchWidget extends BasicWidget {
129196
setTimeout(() => tooltip.dispose(), 4000);
130197
}
131198

132-
const displayedNoteIds = searchResultNoteIds.slice(0, Math.min(MAX_DISPLAYED_NOTES, searchResultNoteIds.length));
199+
// Store all results for infinite scrolling
200+
this.allSearchResults = searchResults || [];
201+
this.allSearchResultNoteIds = searchResultNoteIds || [];
133202

134203
this.$dropdownMenu.empty();
135204

136-
if (displayedNoteIds.length === 0) {
205+
if (this.allSearchResults.length === 0 && this.allSearchResultNoteIds.length === 0) {
137206
this.$dropdownMenu.append(`<span class="dropdown-item disabled">${t("quick-search.no-results")}</span>`);
207+
return;
138208
}
139209

140-
for (const note of await froca.getNotes(displayedNoteIds)) {
141-
const $link = await linkService.createLink(note.noteId, { showNotePath: true, showNoteIcon: true });
142-
$link.addClass("dropdown-item");
143-
$link.attr("tabIndex", "0");
144-
$link.on("click", (e) => {
145-
this.dropdown.hide();
210+
// Display initial batch
211+
await this.displayMoreResults(INITIAL_DISPLAYED_NOTES);
212+
this.addShowInFullSearchButton();
213+
214+
this.dropdown.update();
215+
}
216+
217+
private async displayMoreResults(batchSize: number) {
218+
if (this.isLoadingMore) return;
219+
this.isLoadingMore = true;
220+
221+
// Remove the "Show in full search" button temporarily
222+
this.$dropdownMenu.find('.show-in-full-search').remove();
223+
this.$dropdownMenu.find('.dropdown-divider').remove();
224+
225+
// Use highlighted search results if available, otherwise fall back to basic display
226+
if (this.allSearchResults.length > 0) {
227+
const startIndex = this.currentDisplayedCount;
228+
const endIndex = Math.min(startIndex + batchSize, this.allSearchResults.length);
229+
const resultsToDisplay = this.allSearchResults.slice(startIndex, endIndex);
230+
231+
for (const result of resultsToDisplay) {
232+
const noteId = result.notePath.split("/").pop();
233+
if (!noteId) continue;
234+
235+
const $item = $('<a class="dropdown-item" tabindex="0" href="javascript:">');
236+
237+
// Build the display HTML with content snippet below the title
238+
let itemHtml = `<div style="display: flex; flex-direction: column;">
239+
<div style="display: flex; align-items: flex-start; gap: 6px;">
240+
<span class="${result.icon}" style="flex-shrink: 0; margin-top: 1px;"></span>
241+
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
242+
</div>`;
243+
244+
// Add content snippet below the title if available
245+
if (result.highlightedContentSnippet) {
246+
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
247+
}
248+
249+
itemHtml += `</div>`;
250+
251+
$item.html(itemHtml);
252+
253+
$item.on("click", (e) => {
254+
this.dropdown.hide();
255+
e.preventDefault();
256+
257+
const activeContext = appContext.tabManager.getActiveContext();
258+
if (activeContext) {
259+
activeContext.setNote(noteId);
260+
}
261+
});
262+
263+
shortcutService.bindElShortcut($item, "return", () => {
264+
this.dropdown.hide();
265+
266+
const activeContext = appContext.tabManager.getActiveContext();
267+
if (activeContext) {
268+
activeContext.setNote(noteId);
269+
}
270+
});
271+
272+
this.$dropdownMenu.append($item);
273+
}
274+
275+
this.currentDisplayedCount = endIndex;
276+
} else {
277+
// Fallback to original behavior if no highlighted results
278+
const startIndex = this.currentDisplayedCount;
279+
const endIndex = Math.min(startIndex + batchSize, this.allSearchResultNoteIds.length);
280+
const noteIdsToDisplay = this.allSearchResultNoteIds.slice(startIndex, endIndex);
281+
282+
for (const note of await froca.getNotes(noteIdsToDisplay)) {
283+
const $link = await linkService.createLink(note.noteId, { showNotePath: true, showNoteIcon: true });
284+
$link.addClass("dropdown-item");
285+
$link.attr("tabIndex", "0");
286+
$link.on("click", (e) => {
287+
this.dropdown.hide();
288+
289+
if (!e.target || e.target.nodeName !== "A") {
290+
// click on the link is handled by link handling, but we want the whole item clickable
291+
const activeContext = appContext.tabManager.getActiveContext();
292+
if (activeContext) {
293+
activeContext.setNote(note.noteId);
294+
}
295+
}
296+
});
297+
shortcutService.bindElShortcut($link, "return", () => {
298+
this.dropdown.hide();
146299

147-
if (!e.target || e.target.nodeName !== "A") {
148-
// click on the link is handled by link handling, but we want the whole item clickable
149300
const activeContext = appContext.tabManager.getActiveContext();
150301
if (activeContext) {
151302
activeContext.setNote(note.noteId);
152303
}
153-
}
154-
});
155-
shortcutService.bindElShortcut($link, "return", () => {
156-
this.dropdown.hide();
304+
});
157305

158-
const activeContext = appContext.tabManager.getActiveContext();
159-
if (activeContext) {
160-
activeContext.setNote(note.noteId);
161-
}
162-
});
306+
this.$dropdownMenu.append($link);
307+
}
163308

164-
this.$dropdownMenu.append($link);
309+
this.currentDisplayedCount = endIndex;
165310
}
166311

167-
if (searchResultNoteIds.length > MAX_DISPLAYED_NOTES) {
168-
const numRemainingResults = searchResultNoteIds.length - MAX_DISPLAYED_NOTES;
169-
this.$dropdownMenu.append(`<span class="dropdown-item disabled">${t("quick-search.more-results", { number: numRemainingResults })}</span>`);
312+
this.isLoadingMore = false;
313+
}
314+
315+
private handleScroll() {
316+
if (this.isLoadingMore) return;
317+
318+
const dropdown = this.$dropdownMenu[0];
319+
const scrollTop = dropdown.scrollTop;
320+
const scrollHeight = dropdown.scrollHeight;
321+
const clientHeight = dropdown.clientHeight;
322+
323+
// Trigger loading more when user scrolls near the bottom (within 50px)
324+
if (scrollTop + clientHeight >= scrollHeight - 50) {
325+
const totalResults = this.allSearchResults.length > 0 ? this.allSearchResults.length : this.allSearchResultNoteIds.length;
326+
327+
if (this.currentDisplayedCount < totalResults) {
328+
this.displayMoreResults(LOAD_MORE_BATCH_SIZE).then(() => {
329+
this.addShowInFullSearchButton();
330+
});
331+
}
170332
}
333+
}
334+
335+
private addShowInFullSearchButton() {
336+
// Remove existing button if it exists
337+
this.$dropdownMenu.find('.show-in-full-search').remove();
338+
this.$dropdownMenu.find('.dropdown-divider').remove();
171339

172-
const $showInFullButton = $('<a class="dropdown-item" tabindex="0">').text(t("quick-search.show-in-full-search"));
340+
const $showInFullButton = $('<a class="dropdown-item show-in-full-search" tabindex="0">').text(t("quick-search.show-in-full-search"));
173341

174342
this.$dropdownMenu.append($(`<div class="dropdown-divider">`));
175343
this.$dropdownMenu.append($showInFullButton);

apps/server/src/routes/api/search.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,15 @@ function quickSearch(req: Request) {
5252
fuzzyAttributeSearch: false
5353
});
5454

55-
const resultNoteIds = searchService.findResultsWithQuery(searchString, searchContext).map((sr) => sr.noteId);
55+
// Use the same highlighting logic as autocomplete for consistency
56+
const searchResults = searchService.searchNotesForAutocomplete(searchString, false);
57+
58+
// Extract note IDs for backward compatibility
59+
const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[];
5660

5761
return {
5862
searchResultNoteIds: resultNoteIds,
63+
searchResults: searchResults,
5964
error: searchContext.getError()
6065
};
6166
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { processMindmapContent } from "./note_content_fulltext.js";
3+
import NoteContentFulltextExp from "./note_content_fulltext.js";
34

45
describe("processMindmapContent", () => {
56
it("supports empty JSON", () => {
@@ -11,3 +12,19 @@ describe("processMindmapContent", () => {
1112
expect(processMindmapContent(`{ "node": " }`)).toEqual("");
1213
});
1314
});
15+
16+
describe("Fuzzy Search Operators", () => {
17+
it("~= operator works with typos", () => {
18+
// Test that the ~= operator can handle common typos
19+
const expression = new NoteContentFulltextExp("~=", { tokens: ["hello"] });
20+
expect(expression.tokens).toEqual(["hello"]);
21+
expect(() => new NoteContentFulltextExp("~=", { tokens: ["he"] })).toThrow(); // Too short
22+
});
23+
24+
it("~* operator works with fuzzy contains", () => {
25+
// Test that the ~* operator handles fuzzy substring matching
26+
const expression = new NoteContentFulltextExp("~*", { tokens: ["world"] });
27+
expect(expression.tokens).toEqual(["world"]);
28+
expect(() => new NoteContentFulltextExp("~*", { tokens: ["wo"] })).toThrow(); // Too short
29+
});
30+
});

0 commit comments

Comments
 (0)