Skip to content

Commit ad864cf

Browse files
authored
feat(search): try to improve search performance through some creative mechanisms... (#9034)
2 parents 88743f1 + f58dd12 commit ad864cf

19 files changed

Lines changed: 1739 additions & 110 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ Trilium provides powerful user scripting capabilities:
162162
- To add a new user preference:
163163
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
164164
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
165-
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
166-
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
165+
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to the `ALLOWED_OPTIONS` array — **without this, the API will reject changes with "Option 'X' is not allowed to be changed"**
166+
4. If the option should be user-editable in the UI, add a control in the appropriate settings component (e.g., `apps/client/src/widgets/type_widgets/options/other.tsx`) and a translation key in `apps/client/src/translations/en/translation.json`
167+
5. Use `useTriliumOption("optionName")` hook in React components to read/write the option
167168
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
168169
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
169170

apps/client/src/translations/en/translation.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,13 @@
13241324
"erase_excess_revision_snapshots": "Erase excess revision snapshots now",
13251325
"erase_excess_revision_snapshots_prompt": "Excess revision snapshots have been erased."
13261326
},
1327+
"search": {
1328+
"title": "Search",
1329+
"fuzzy_matching_label": "Typo tolerance in search",
1330+
"fuzzy_matching_description": "Affects quick search and full search. Finds similar words when exact matches are insufficient.",
1331+
"autocomplete_fuzzy_label": "Typo tolerance in autocomplete",
1332+
"autocomplete_fuzzy_description": "Affects jump-to-note and note selectors. Slower but tolerates typos."
1333+
},
13271334
"search_engine": {
13281335
"title": "Search Engine",
13291336
"custom_search_engine_info": "Custom search engine requires both a name and a URL to be set. If either of these is not set, DuckDuckGo will be used as the default search engine.",

apps/client/src/widgets/type_widgets/options/other.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import FormGroup from "../../react/FormGroup";
1414
import FormSelect from "../../react/FormSelect";
1515
import FormText from "../../react/FormText";
1616
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
17+
import FormToggle from "../../react/FormToggle";
1718
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
19+
import OptionsRow from "./components/OptionsRow";
1820
import OptionsSection from "./components/OptionsSection";
1921
import TimeSelector from "./components/TimeSelector";
2022

2123
export default function OtherSettings() {
2224
return (
2325
<>
26+
<SearchSettings />
2427
{isElectron() && <>
2528
<SearchEngineSettings />
2629
<TrayOptionsSettings />
@@ -36,6 +39,39 @@ export default function OtherSettings() {
3639
);
3740
}
3841

42+
function SearchSettings() {
43+
const [ fuzzyEnabled, setFuzzyEnabled ] = useTriliumOptionBool("searchEnableFuzzyMatching");
44+
const [ autocompleteFuzzy, setAutocompleteFuzzy ] = useTriliumOptionBool("searchAutocompleteFuzzy");
45+
46+
return (
47+
<OptionsSection title={t("search.title")}>
48+
<OptionsRow
49+
name="search-fuzzy-matching"
50+
label={t("search.fuzzy_matching_label")}
51+
description={t("search.fuzzy_matching_description")}
52+
>
53+
<FormToggle
54+
switchOnName="" switchOffName=""
55+
currentValue={fuzzyEnabled}
56+
onChange={setFuzzyEnabled}
57+
/>
58+
</OptionsRow>
59+
60+
<OptionsRow
61+
name="search-autocomplete-fuzzy"
62+
label={t("search.autocomplete_fuzzy_label")}
63+
description={t("search.autocomplete_fuzzy_description")}
64+
>
65+
<FormToggle
66+
switchOnName="" switchOffName=""
67+
currentValue={autocompleteFuzzy}
68+
onChange={setAutocompleteFuzzy}
69+
/>
70+
</OptionsRow>
71+
</OptionsSection>
72+
);
73+
}
74+
3975
function SearchEngineSettings() {
4076
const [ customSearchEngineName, setCustomSearchEngineName ] = useTriliumOption("customSearchEngineName");
4177
const [ customSearchEngineUrl, setCustomSearchEngineUrl ] = useTriliumOption("customSearchEngineUrl");

apps/server/spec/db/document.db

8 KB
Binary file not shown.

apps/server/src/becca/becca-interface.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import sql from "../services/sql.js";
2+
import log from "../services/log.js";
3+
import { formatSize } from "../services/utils.js";
24
import NoteSet from "../services/search/note_set.js";
35
import NotFoundError from "../errors/not_found_error.js";
46
import type BOption from "./entities/boption.js";
@@ -31,9 +33,22 @@ export default class Becca {
3133

3234
allNoteSetCache: NoteSet | null;
3335

36+
/**
37+
* Pre-built parallel arrays for fast flat text scanning in search.
38+
* Avoids per-note property access overhead when iterating 50K+ notes.
39+
* Supports incremental updates: when individual notes change, only their
40+
* entries are rebuilt rather than the entire index.
41+
*/
42+
flatTextIndex: { notes: BNote[], flatTexts: string[], noteIdToIdx: Map<string, number> } | null;
43+
44+
/** NoteIds whose flat text needs to be recomputed in the index. */
45+
dirtyFlatTextNoteIds: Set<string>;
46+
3447
constructor() {
35-
this.reset();
48+
this.dirtyFlatTextNoteIds = new Set();
3649
this.allNoteSetCache = null;
50+
this.flatTextIndex = null;
51+
this.reset();
3752
}
3853

3954
reset() {
@@ -242,6 +257,67 @@ export default class Becca {
242257
/** Should be called when the set of all non-skeleton notes changes (added/removed) */
243258
dirtyNoteSetCache() {
244259
this.allNoteSetCache = null;
260+
// Full rebuild needed since the note set itself changed
261+
this.flatTextIndex = null;
262+
this.dirtyFlatTextNoteIds.clear();
263+
}
264+
265+
/** Mark a single note's flat text as needing recomputation in the index. */
266+
dirtyNoteFlatText(noteId: string) {
267+
if (this.flatTextIndex) {
268+
// Index exists — schedule an incremental update
269+
this.dirtyFlatTextNoteIds.add(noteId);
270+
}
271+
// If flatTextIndex is null, full rebuild will happen on next access anyway
272+
}
273+
274+
/**
275+
* Returns pre-built parallel arrays of notes and their flat texts for fast scanning.
276+
* The flat texts are already normalized (lowercase, diacritics removed).
277+
* Supports incremental updates: when individual notes are dirtied, only their
278+
* entries are recomputed rather than rebuilding the entire index.
279+
*/
280+
getFlatTextIndex(): { notes: BNote[], flatTexts: string[], noteIdToIdx: Map<string, number> } {
281+
if (!this.flatTextIndex) {
282+
// Measure heap before building
283+
const heapBefore = process.memoryUsage().heapUsed;
284+
285+
const allNoteSet = this.getAllNoteSet();
286+
const notes: BNote[] = [];
287+
const flatTexts: string[] = [];
288+
const noteIdToIdx = new Map<string, number>();
289+
290+
for (const note of allNoteSet.notes) {
291+
noteIdToIdx.set(note.noteId, notes.length);
292+
notes.push(note);
293+
flatTexts.push(note.getFlatText());
294+
}
295+
296+
this.flatTextIndex = { notes, flatTexts, noteIdToIdx };
297+
this.dirtyFlatTextNoteIds.clear();
298+
299+
// Measure heap after building and log
300+
const heapAfter = process.memoryUsage().heapUsed;
301+
const heapDelta = heapAfter - heapBefore;
302+
log.info(`Flat text search index built: ${notes.length} notes, ${formatSize(heapDelta)}`);
303+
} else if (this.dirtyFlatTextNoteIds.size > 0) {
304+
// Incremental update: only recompute flat texts for dirtied notes
305+
const { flatTexts, noteIdToIdx } = this.flatTextIndex;
306+
307+
for (const noteId of this.dirtyFlatTextNoteIds) {
308+
const idx = noteIdToIdx.get(noteId);
309+
if (idx !== undefined) {
310+
const note = this.notes[noteId];
311+
if (note) {
312+
flatTexts[idx] = note.getFlatText();
313+
}
314+
}
315+
}
316+
317+
this.dirtyFlatTextNoteIds.clear();
318+
}
319+
320+
return this.flatTextIndex;
245321
}
246322

247323
getAllNoteSet() {

apps/server/src/becca/entities/battribute.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dateUtils from "../../services/date_utils.js";
66
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
77
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
88
import type { AttributeRow, AttributeType } from "@triliumnext/commons";
9+
import { normalize } from "../../services/utils.js";
910

1011
interface SavingOpts {
1112
skipValidation?: boolean;
@@ -34,6 +35,11 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
3435
value!: string;
3536
isInheritable!: boolean;
3637

38+
/** Pre-normalized (lowercase, diacritics removed) name for search. */
39+
normalizedName!: string;
40+
/** Pre-normalized (lowercase, diacritics removed) value for search. */
41+
normalizedValue!: string;
42+
3743
constructor(row?: AttributeRow) {
3844
super();
3945

@@ -59,6 +65,10 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
5965
this.isInheritable = !!isInheritable;
6066
this.utcDateModified = utcDateModified;
6167

68+
// Pre-compute normalized forms for search (avoids repeated normalize() calls in hot loops)
69+
this.normalizedName = normalize(this.name);
70+
this.normalizedValue = normalize(this.value);
71+
6272
return this;
6373
}
6474

@@ -192,6 +202,11 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
192202

193203
this.utcDateModified = dateUtils.utcNowDateTime();
194204

205+
// Recompute normalized fields in case name/value were modified directly
206+
// (e.g., attr.value = "..." followed by attr.save())
207+
this.normalizedName = normalize(this.name);
208+
this.normalizedValue = normalize(this.value);
209+
195210
super.beforeSaving();
196211

197212
this.becca.attributes[this.attributeId] = this;

apps/server/src/becca/entities/bnote.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
802802
this.__attributeCache = null;
803803
this.__inheritableAttributeCache = null;
804804
this.__ancestorCache = null;
805+
806+
// Mark only this note's flat text as dirty for incremental index update
807+
this.becca.dirtyNoteFlatText(this.noteId);
805808
}
806809

807810
invalidateSubTree(path: string[] = []) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
9999
"layoutOrientation",
100100
"backgroundEffects",
101101
"allowedHtmlTags",
102+
"searchEnableFuzzyMatching",
103+
"searchAutocompleteFuzzy",
102104
"redirectBareDomain",
103105
"showLoginInShareTheme",
104106
"splitEditorOrientation",

apps/server/src/services/options_init.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ const defaultOptions: DefaultOption[] = [
234234
isSynced: true
235235
},
236236

237+
// Search settings
238+
{ name: "searchEnableFuzzyMatching", value: "true", isSynced: true },
239+
{ name: "searchAutocompleteFuzzy", value: "false", isSynced: true },
240+
237241
// Share settings
238242
{ name: "redirectBareDomain", value: "false", isSynced: true },
239243
{ name: "showLoginInShareTheme", value: "false", isSynced: true },

0 commit comments

Comments
 (0)