fix(search): confine quick search to the hoisted subtree (closes #10803)

Quick search widened its scope to the whole tree whenever the hoisted note
lived inside _hidden, so browsing the in-app help returned the user's own
notes instead of help pages. It also passed includeHiddenNotes, which made an
unhoisted quick search return notes that have no visible path at all.

Neither setting was chosen for quick search. Both arrived together in
8e227a6146, a commit about highlighting exact matches, which copied the
search context of searchNotesForAutocomplete verbatim into the quickSearch
route.

The widening belongs to autocomplete alone. It was added in 6a9ac6f90a for
the launcher work: while hoisted into the hidden configuration area, the
note-link autocomplete still has to reach normal notes so a launcher can
point at one. It first sat in the SearchContext constructor, where it applied
to every search that passed no explicit ancestor, and 88bc7402a2 deliberately
narrowed it to the one function that needs it, introducing includeHiddenNotes
and the IsHiddenExp filter at the same time. That commit's subject states the
intent this one restores: hidden notes should not appear in the global search
unless hoisted into it.

Quick search now scopes to the hoisted note with no special case, so a hoist
into the hidden subtree confines it there like any other hoist. The two
settings have to move together: getAncestorExp only applies the hidden filter
when the ancestor is root, so dropping includeHiddenNotes while keeping the
widening would leave a hoist into help searching everything except help.

Hoisting to _hidden itself now yields nothing, because getSubtree defaults to
includeHidden: false and stops at that node. Hidden descendants, help and the
launcher bar included, traverse normally. Letting AncestorExp traverse hidden
notes would fix that, but the expression also backs saved searches and ETAPI
queries, so it is left for a separate change.

CoreApiTester never seeded the execution context from request headers, so no
spec could describe a hoisted request; the route read "root" whatever the
spec did. It now seeds hoistedNoteId, componentId and localNowDateTime from
the trilium-* headers, matching route_api.ts on Express and browser_routes.ts
in standalone. The new specs create their hidden note under _lbBookmarks
because the API refuses children directly under _hidden and _help*.

The comment in SearchContext explaining the widening has documented a
condition that no longer exists since 88bc7402a2 and is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-09-11 23:12:56 +02:00
parent 73edb95583
commit 7edff12d9b
No known key found for this signature in database
4 changed files with 69 additions and 9 deletions

View File

@ -15,6 +15,13 @@ async function createSearchNote(searchString: string): Promise<string> {
return created.body.noteId; return created.body.noteId;
} }
/** Runs a quick search as a client hoisted into `hoistedNoteId`, or unhoisted when it is omitted. */
function quickSearch(searchString: string, hoistedNoteId?: string) {
return api.get<{ searchResultNoteIds: string[] }>(`/api/quick-search/${searchString}`, {
headers: hoistedNoteId ? { "trilium-hoisted-note-id": hoistedNoteId } : undefined
});
}
describe("Search API (core)", () => { describe("Search API (core)", () => {
let createdNoteId: string; let createdNoteId: string;
@ -95,6 +102,37 @@ describe("Search API (core)", () => {
expect(res.body.highlightedTokens).not.toContain(pattern); expect(res.body.highlightedTokens).not.toContain(pattern);
}); });
it("confines a quick search to the hoisted subtree, hidden subtree included", async () => {
const token = "ZzHoistedQuickSearchQwerty";
const workspace = await createTextNote(api, { title: "Workspace" });
const inside = await createTextNote(api, { parentNoteId: workspace.noteId, title: `${token} inside` });
const outside = await createTextNote(api, { title: `${token} outside` });
const hidden = await createTextNote(api, { parentNoteId: "_lbBookmarks", title: `${token} hidden` });
const inWorkspace = await quickSearch(token, workspace.noteId);
expect(inWorkspace.status).toBe(200);
expect(inWorkspace.body.searchResultNoteIds).toContain(inside.noteId);
expect(inWorkspace.body.searchResultNoteIds).not.toContain(outside.noteId);
// A hoist into the hidden subtree scopes the search to that subtree like any other hoist,
// so browsing the in-app help does not surface the user's own notes.
const inHidden = await quickSearch(token, "_lbBookmarks");
expect(inHidden.status).toBe(200);
expect(inHidden.body.searchResultNoteIds).toContain(hidden.noteId);
expect(inHidden.body.searchResultNoteIds).not.toContain(outside.noteId);
});
it("omits hidden notes from an unhoisted quick search", async () => {
const token = "ZzUnhoistedQuickSearchQwerty";
const visible = await createTextNote(api, { title: `${token} visible` });
const hidden = await createTextNote(api, { parentNoteId: "_lbBookmarks", title: `${token} hidden` });
const res = await quickSearch(token);
expect(res.status).toBe(200);
expect(res.body.searchResultNoteIds).toContain(visible.noteId);
expect(res.body.searchResultNoteIds).not.toContain(hidden.noteId);
});
it("lists template note ids including a freshly-labelled template", async () => { it("lists template note ids including a freshly-labelled template", async () => {
const { noteId } = await createTextNote(api, { title: "A template note" }); const { noteId } = await createTextNote(api, { title: "A template note" });
await api.post(`/api/notes/${noteId}/attributes`, { await api.post(`/api/notes/${noteId}/attributes`, {

View File

@ -116,10 +116,11 @@ function quickSearch(req: Request<{ searchString: string }>) {
const searchContext = new SearchContext({ const searchContext = new SearchContext({
fastSearch: false, fastSearch: false,
includeArchivedNotes: false, includeArchivedNotes: false,
includeHiddenNotes: true,
fuzzyAttributeSearch: true, fuzzyAttributeSearch: true,
ignoreInternalAttributes: true, ignoreInternalAttributes: true,
ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId() // Quick search covers the subtree the user is looking at, so a hoist into the hidden
// subtree scopes it there too. Only link autocomplete widens to root, for link targets.
ancestorNoteId: hoistedNoteService.getHoistedNoteId()
}); });
const trimmed = searchService.findResultsWithQuery(searchString, searchContext).slice(0, 200); const trimmed = searchService.findResultsWithQuery(searchString, searchContext).slice(0, 200);

View File

@ -51,8 +51,6 @@ class SearchContext {
this.ancestorNoteId = params.ancestorNoteId; this.ancestorNoteId = params.ancestorNoteId;
if (!this.ancestorNoteId && !this.ignoreHoistedNote) { if (!this.ancestorNoteId && !this.ignoreHoistedNote) {
// hoisting in hidden subtree should not limit autocomplete
// since we want to link (create relations) to the normal non-hidden notes
this.ancestorNoteId = hoistedNoteService.getHoistedNoteId(); this.ancestorNoteId = hoistedNoteService.getHoistedNoteId();
} }

View File

@ -215,14 +215,19 @@ export class CoreApiTester {
private registerAll() { private registerAll() {
const apiRoute = (method: HttpMethod, path: string, handler: Handler) => const apiRoute = (method: HttpMethod, path: string, handler: Handler) =>
this.add(method, path, async (req) => { this.add(method, path, async (req) => {
const result = await getContext().init(() => const result = await getContext().init(() => {
getSql().transactional(() => handler(req))); seedContext(req);
return getSql().transactional(() => handler(req));
});
return formatApiResult(result); return formatApiResult(result);
}); });
const asyncApiRoute = (method: HttpMethod, path: string, handler: Handler) => const asyncApiRoute = (method: HttpMethod, path: string, handler: Handler) =>
this.add(method, path, async (req) => { this.add(method, path, async (req) => {
const result = await getContext().init(async () => await handler(req)); const result = await getContext().init(async () => {
seedContext(req);
return await handler(req);
});
return formatApiResult(result); return formatApiResult(result);
}); });
@ -238,8 +243,14 @@ export class CoreApiTester {
const mockRes = createMockResponse(); const mockRes = createMockResponse();
const invoke = () => handler(req, mockRes); const invoke = () => handler(req, mockRes);
const result = transactional const result = transactional
? await getContext().init(() => getSql().transactional(invoke)) ? await getContext().init(() => {
: await getContext().init(async () => await invoke()); seedContext(req);
return getSql().transactional(invoke);
})
: await getContext().init(async () => {
seedContext(req);
return await invoke();
});
if (mockRes.used) { if (mockRes.used) {
return mockRes.snapshot(); return mockRes.snapshot();
@ -363,3 +374,15 @@ export class CoreApiTester {
} }
} }
/**
* Seeds the execution context from the `trilium-*` request headers, the way both real adapters do
* (`route_api.ts` on Express, `browser_routes.ts` in standalone). Routes that read the hoisted note
* through `cls.getHoistedNoteId()` need this to see anything other than "root".
*/
function seedContext(req: ApiRequest) {
const ctx = getContext();
ctx.set("componentId", req.get("trilium-component-id"));
ctx.set("localNowDateTime", req.get("trilium-local-now-datetime"));
ctx.set("hoistedNoteId", req.get("trilium-hoisted-note-id") || "root");
}