refactor: consolidate note-list body search

This commit is contained in:
lucaronin
2026-05-29 03:19:59 +02:00
parent 78892c4637
commit 6bf70eb635
10 changed files with 156 additions and 97 deletions

View File

@@ -771,7 +771,7 @@ interface SearchResult {
- Click result to open note in editor
- Shows relevance score and snippet
The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` full-content hits from the visible workspace roots. It verifies backend hits against Markdown body text with frontmatter excluded, and stores only matching paths so body-only matches appear in the current list scope without rendering private matched text in note rows.
The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` hits from the visible workspace roots using the command's frontmatter-excluding search option. React stores only matching paths so body-only matches appear in the current list scope without a second content-read pass or rendering private matched text in note rows.
No indexing step required — search runs directly against the filesystem.

View File

@@ -454,7 +454,7 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend full-content hits are verified against Markdown body text, excluding frontmatter, before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row.
The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend body-content hits use `search_vault` with frontmatter excluded before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row.
## Vault Cache System

View File

@@ -100,14 +100,24 @@ pub async fn reload_vault(
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
exclude_frontmatter: Option<bool>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
let exclude_frontmatter = exclude_frontmatter.unwrap_or(false);
tokio::task::spawn_blocking(move || {
search::search_vault_with_options(search::SearchOptions {
vault_path: &vault_path,
query: &query,
mode: "keyword",
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
exclude_frontmatter,
})
})
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[cfg(test)]
@@ -264,7 +274,7 @@ mod tests {
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
"keyword".to_string(),
None,
None,
)
.await
@@ -287,8 +297,8 @@ mod tests {
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
"keyword".to_string(),
Some(1),
None,
)
.await
.unwrap();

View File

@@ -26,6 +26,7 @@ pub struct SearchOptions<'a> {
pub mode: &'a str,
pub limit: usize,
pub hide_gitignored_files: bool,
pub exclude_frontmatter: bool,
}
struct Utf8Boundary<'a> {
@@ -126,9 +127,29 @@ pub fn search_vault(
mode: _mode,
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
exclude_frontmatter: false,
})
}
fn strip_frontmatter(content: &str) -> &str {
let Some(rest) = content.strip_prefix("---") else {
return content;
};
match rest.find("\n---") {
Some(end) => rest[end + 4..].trim_start(),
None => content,
}
}
fn searchable_content(content: &str, exclude_frontmatter: bool) -> &str {
if exclude_frontmatter {
strip_frontmatter(content)
} else {
content
}
}
fn is_markdown_search_candidate(vault_dir: &Path, path: &Path) -> bool {
if !path.extension().is_some_and(|ext| ext == "md") {
return false;
@@ -164,7 +185,8 @@ pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchRes
Err(_) => continue,
};
let content_lower = content.to_lowercase();
let searchable_content = searchable_content(&content, options.exclude_frontmatter);
let content_lower = searchable_content.to_lowercase();
let filename = path
.file_name()
.and_then(|value| value.to_str())
@@ -183,7 +205,7 @@ pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchRes
}
.score();
let snippet = SnippetRequest {
content: &content,
content: searchable_content,
query_lower: &query_lower,
}
.extract();
@@ -364,6 +386,7 @@ mod tests {
mode: "keyword",
limit: 10,
hide_gitignored_files: true,
exclude_frontmatter: false,
})
.unwrap();
let shown = search_vault_with_options(SearchOptions {
@@ -372,6 +395,7 @@ mod tests {
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
exclude_frontmatter: false,
})
.unwrap();
@@ -379,4 +403,44 @@ mod tests {
assert_eq!(hidden.results[0].title, "Visible");
assert_eq!(shown.results.len(), 2);
}
#[test]
fn test_search_vault_can_exclude_frontmatter_from_content_matches() {
let dir = Builder::new()
.prefix("search-frontmatter-scope-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
fs::write(
dir.path().join("frontmatter-only.md"),
[
"---",
"Owner: hidden-frontmatter-keyword",
"---",
"",
"# Public Body",
"",
"The note body deliberately omits the hidden property token.",
]
.join("\n"),
)
.unwrap();
fs::write(
dir.path().join("body-match.md"),
"# Body Match\n\nBody includes hidden-frontmatter-keyword here.",
)
.unwrap();
let response = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "hidden-frontmatter-keyword",
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
exclude_frontmatter: true,
})
.unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Body Match");
}
}

View File

@@ -118,10 +118,8 @@ interface NoteListSearchMockResult {
}
function installFullTextSearchMocks({
contentByPath,
resultsByVault,
}: {
contentByPath: Record<string, string>
resultsByVault: Record<string, NoteListSearchMockResult[]>
}) {
const originalContentHandler = window.__mockHandlers?.get_note_content
@@ -132,7 +130,9 @@ function installFullTextSearchMocks({
query: args?.query,
results: resultsByVault[String(args?.vaultPath ?? '')] ?? [],
}))
const getNoteContent = vi.fn((args?: Record<string, unknown>) => contentByPath[String(args?.path ?? '')] ?? '')
const getNoteContent = vi.fn(() => {
throw new Error('Note-list full-text search should not read note content in React')
})
if (!window.__mockHandlers) window.__mockHandlers = {}
window.__mockHandlers.search_vault = searchVault
@@ -291,10 +291,7 @@ describe('NoteList rendering', () => {
})
it('filters by full note content when the title and snippet do not match', async () => {
const { restore, searchVault } = installFullTextSearchMocks({
contentByPath: {
'/vault/b.md': '# Beta Note\n\nA private subterranean-keyword body match.',
},
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
resultsByVault: {
'/vault': [{
note_type: 'Note',
@@ -321,8 +318,10 @@ describe('NoteList rendering', () => {
vaultPath: '/vault',
query: 'subterranean-keyword',
mode: 'keyword',
excludeFrontmatter: true,
}))
})
expect(getNoteContent).not.toHaveBeenCalled()
expect(screen.getByText('Beta Note')).toBeInTheDocument()
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
expect(screen.queryByText('Private body match is intentionally not rendered here.')).not.toBeInTheDocument()
@@ -332,26 +331,9 @@ describe('NoteList rendering', () => {
})
it('ignores full-content matches that only appear in hidden frontmatter', async () => {
const { restore } = installFullTextSearchMocks({
contentByPath: {
'/vault/a.md': [
'---',
'Owner: hidden-frontmatter-keyword',
'---',
'',
'# Alpha Note',
'',
'Public body text omits the private property value.',
].join('\n'),
},
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
resultsByVault: {
'/vault': [{
note_type: 'Note',
path: '/vault/a.md',
score: 1,
snippet: 'Owner: hidden-frontmatter-keyword',
title: 'Alpha Note',
}],
'/vault': [],
},
})
@@ -365,6 +347,12 @@ describe('NoteList rendering', () => {
await searchNoteList('hidden-frontmatter-keyword')
await waitFor(() => {
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({
excludeFrontmatter: true,
}))
})
expect(getNoteContent).not.toHaveBeenCalled()
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
expect(screen.getByText('No matching notes')).toBeInTheDocument()
} finally {
@@ -373,11 +361,7 @@ describe('NoteList rendering', () => {
})
it('runs full-content note-list search across visible workspaces', async () => {
const { restore, searchVault } = installFullTextSearchMocks({
contentByPath: {
'/personal/personal-note.md': '# Personal Note\n\nNo matching body token.',
'/team/team-body-hit.md': '# Team Body Hit\n\nPrivate workspace-only-keyword body hit.',
},
const { getNoteContent, restore, searchVault } = installFullTextSearchMocks({
resultsByVault: {
'/team': [{
note_type: 'Note',
@@ -412,9 +396,10 @@ describe('NoteList rendering', () => {
await searchNoteList('workspace-only-keyword')
await waitFor(() => {
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/personal' }))
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/team' }))
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/personal', excludeFrontmatter: true }))
expect(searchVault).toHaveBeenCalledWith(expect.objectContaining({ vaultPath: '/team', excludeFrontmatter: true }))
})
expect(getNoteContent).not.toHaveBeenCalled()
expect(screen.getByText('Team Body Hit')).toBeInTheDocument()
expect(screen.queryByText('Personal Note')).not.toBeInTheDocument()
} finally {

View File

@@ -3,7 +3,6 @@ import { invoke } from '@tauri-apps/api/core'
import type { VaultEntry } from '../../types'
import { isTauri, mockInvoke } from '../../mock-tauri'
type MarkdownContent = string
type NoteListSearchQuery = string
type NotePath = string
type VaultPath = string
@@ -22,6 +21,7 @@ interface SearchCommandArgs extends Record<string, unknown> {
query: NoteListSearchQuery
mode: 'keyword'
limit: number
excludeFrontmatter: true
}
interface FullTextSearchRequest {
@@ -85,51 +85,14 @@ function resolveNoteListSearchVaultPaths(entries: VaultEntry[]): VaultPath[] {
return root ? [root] : []
}
function stripFrontmatter(content: MarkdownContent): MarkdownContent {
const lineEnding = content.startsWith('---\r\n')
? '\r\n'
: content.startsWith('---\n') ? '\n' : null
if (!lineEnding) return content
const afterOpen = content.slice(3 + lineEnding.length)
const closeMarker = `${lineEnding}---`
const closeIndex = afterOpen.indexOf(closeMarker)
if (closeIndex === -1) return content
return stripFrontmatterCloseLine(afterOpen.slice(closeIndex + closeMarker.length))
}
function stripFrontmatterCloseLine(content: MarkdownContent): MarkdownContent {
if (content.startsWith('\r\n')) return content.slice(2)
return content.startsWith('\n') ? content.slice(1) : content
}
function searchVault(args: SearchCommandArgs): Promise<SearchResponseData> {
return isTauri()
? invoke<SearchResponseData>('search_vault', args)
: mockInvoke<SearchResponseData>('search_vault', args)
}
function readNoteContent(path: NotePath): Promise<MarkdownContent> {
return isTauri()
? invoke<MarkdownContent>('get_note_content', { path })
: mockInvoke<MarkdownContent>('get_note_content', { path })
}
async function bodyContainsQuery({ path, query }: { path: NotePath; query: NoteListSearchQuery }): Promise<boolean> {
try {
const body = stripFrontmatter(await readNoteContent(path))
return body.toLowerCase().includes(query)
} catch {
return false
}
}
async function resolveBodyResultPaths({ paths, query }: { paths: NotePath[]; query: NoteListSearchQuery }): Promise<Set<string>> {
const matches = await Promise.all(unique(paths).map(async (path) => (
await bodyContainsQuery({ path, query }) ? path : null
)))
return new Set(matches.filter((path): path is string => !!path))
function resolveResultPaths(responses: SearchResponseData[]): Set<string> {
return new Set(unique(responses.flatMap((response) => response.results.map((result) => result.path))))
}
async function runFullTextSearch(request: FullTextSearchRequest): Promise<Set<string>> {
@@ -138,11 +101,9 @@ async function runFullTextSearch(request: FullTextSearchRequest): Promise<Set<st
query: request.query,
mode: 'keyword',
limit: request.limit,
excludeFrontmatter: true,
})))
return resolveBodyResultPaths({
paths: responses.flatMap((response) => response.results.map((result) => result.path)),
query: request.query,
})
return resolveResultPaths(responses)
}
function createSearchRequest({ entries, query }: { entries: VaultEntry[]; query: NoteListSearchQuery }): FullTextSearchRequest | null {

View File

@@ -34,6 +34,23 @@ function mockFileHistory(path: string) {
]
}
function stripMockFrontmatter(content: string): string {
const lineEnding = content.startsWith('---\r\n')
? '\r\n'
: content.startsWith('---\n') ? '\n' : null
if (!lineEnding) return content
const afterOpen = content.slice(3 + lineEnding.length)
const closeIndex = afterOpen.indexOf(`${lineEnding}---`)
if (closeIndex === -1) return content
return afterOpen.slice(closeIndex + lineEnding.length + 3).trimStart()
}
function mockSearchContent(content: string, excludeFrontmatter?: boolean): string {
return excludeFrontmatter ? stripMockFrontmatter(content) : content
}
function mockModifiedFiles(): ModifiedFile[] {
return [
{ path: '/Users/luca/Laputa/26q1-laputa-app.md', relativePath: '26q1-laputa-app.md', status: 'modified' },
@@ -563,12 +580,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
search_vault: (args: { query: string; mode: string }) => {
search_vault: (args: { query: string; mode: string; excludeFrontmatter?: boolean }) => {
const q = (args.query ?? '').toLowerCase()
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
const matches = MOCK_ENTRIES
.filter(e => {
const content = MOCK_CONTENT[e.path] ?? ''
const content = mockSearchContent(MOCK_CONTENT[e.path] ?? '', args.excludeFrontmatter)
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
})
.slice(0, 20)

View File

@@ -95,7 +95,9 @@ function buildSearchRequest(args: Record<string, unknown>): VaultApiRequest | nu
if (!query || !lastVaultPath) return null
const mode = argText(payload, 'mode') ?? 'all'
return { kind: 'search', body: { mode, query, vault_path: lastVaultPath } }
const body: Record<string, unknown> = { mode, query, vault_path: lastVaultPath }
if (Reflect.get(payload, 'excludeFrontmatter') === true) body.exclude_frontmatter = true
return { kind: 'search', body }
}
function isPathQueryCommand(cmd: string): cmd is PathQueryCommand {

View File

@@ -336,8 +336,11 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo, folde
)
const query = encodeURIComponent(readCommandString(commandArgs, 'query'))
const mode = encodeURIComponent(readCommandString(commandArgs, 'mode', 'all'))
const excludeFrontmatter = readCommandValue(commandArgs, 'excludeFrontmatter') === true
? '&exclude_frontmatter=1'
: ''
return readJson(
`/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`,
`/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}${excludeFrontmatter}`,
)
},
})

View File

@@ -89,12 +89,14 @@ type FrontmatterPropertyValue = string | number | boolean | null
type VaultSearchResult = { title: string; path: string; snippet: string; score: number; note_type: string | null }
interface SearchEntryInput {
excludeFrontmatter: boolean
entry: VaultEntry
query: string
rawContent: string
}
interface SearchRequestInput {
excludeFrontmatter: boolean
query: string
vaultPath: string
}
@@ -411,6 +413,11 @@ function commandString({ args, key }: CommandStringInput): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function commandBool({ args, key }: CommandStringInput): boolean {
const value = Reflect.get(args, key)
return value === true || value === '1' || value === 'true'
}
function commandResponse({ payload, statusCode = 200 }: CommandResponseInput): VaultCommandResponse {
return { payload, statusCode }
}
@@ -463,7 +470,10 @@ function commandVaultSearch({ args }: VaultCommandContext): VaultCommandResponse
const vaultPath = commandString({ args, key: 'vault_path' })
const query = (commandString({ args, key: 'query' }) ?? '').toLowerCase()
const mode = commandString({ args, key: 'mode' }) ?? 'all'
const results = vaultPath && query ? collectVaultSearchResults({ vaultPath, query }) : []
const excludeFrontmatter = commandBool({ args, key: 'exclude_frontmatter' })
const results = vaultPath && query
? collectVaultSearchResults({ vaultPath, query, excludeFrontmatter })
: []
return commandResponse({ payload: { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode } })
}
@@ -548,7 +558,7 @@ function vaultCommandContext(payload: VaultCommandPayload): VaultCommandContext
return { cmd: payload.cmd, args: payload.args }
}
const VAULT_ENDPOINT_ARG_KEYS = ['path', 'vault_path', 'query', 'mode', 'reload'] as const
const VAULT_ENDPOINT_ARG_KEYS = ['path', 'vault_path', 'query', 'mode', 'reload', 'exclude_frontmatter'] as const
function readVaultQueryArgs(url: URL): Record<string, unknown> {
const args: Record<string, unknown> = {}
@@ -650,19 +660,26 @@ async function handleVaultSearch(url: URL, req: IncomingMessage, res: ServerResp
return handleVaultReadCommand({ cmd: 'search_vault', pathname: '/api/vault/search', req, res, url })
}
function collectVaultSearchResults({ vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
function collectVaultSearchResults({ excludeFrontmatter, vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
const results: VaultSearchResult[] = []
for (const filePath of findMarkdownFiles(vaultPath)) {
const entry = parseMarkdownFile(filePath)
if (!entry || entry.trashed) continue
const rawContent = readUtf8File(filePath)
if (entryMatchesSearch({ entry, rawContent, query })) results.push(searchResultFromEntry(entry))
if (entryMatchesSearch({ entry, rawContent, query, excludeFrontmatter })) {
results.push(searchResultFromEntry(entry))
}
}
return results.slice(0, 20)
}
function entryMatchesSearch({ entry, rawContent, query }: SearchEntryInput): boolean {
return entry.title.toLowerCase().includes(query) || rawContent.toLowerCase().includes(query)
function searchableSearchContent(rawContent: string, excludeFrontmatter: boolean): string {
return excludeFrontmatter ? matter(rawContent).content : rawContent
}
function entryMatchesSearch({ entry, excludeFrontmatter, rawContent, query }: SearchEntryInput): boolean {
const content = searchableSearchContent(rawContent, excludeFrontmatter)
return entry.title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
function searchResultFromEntry(entry: VaultEntry): VaultSearchResult {