feat: add body content filter for Views (contains/does not contain)

Adds 'body' as a built-in filter field that searches note snippet text
(case-insensitive). Visually separated from property fields in the
FilterBuilder dropdown with a separator. Combines with other filters via AND.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-04 16:13:21 +02:00
parent 9f905169cb
commit f8d080e678
5 changed files with 85 additions and 3 deletions

View File

@@ -244,4 +244,61 @@ describe('evaluateView', () => {
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('body contains filters on snippet text (case-insensitive)', () => {
const view: ViewDefinition = {
name: 'Body search', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'contains', value: 'quarterly' }] },
}
const entries = [
makeEntry({ title: 'Match', snippet: 'This is the quarterly review summary' }),
makeEntry({ title: 'No match', snippet: 'Daily standup notes' }),
makeEntry({ title: 'Case match', snippet: 'QUARTERLY PLANNING session' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match', 'Case match'])
})
it('body not_contains excludes matching notes', () => {
const view: ViewDefinition = {
name: 'Body exclude', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'not_contains', value: 'draft' }] },
}
const entries = [
makeEntry({ title: 'Final', snippet: 'Final version of the document' }),
makeEntry({ title: 'Draft', snippet: 'This is a draft version' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Final'])
})
it('body filter combines with property filters (AND)', () => {
const view: ViewDefinition = {
name: 'Combined', icon: null, color: null, sort: null,
filters: { all: [
{ field: 'type', op: 'equals', value: 'Note' },
{ field: 'body', op: 'contains', value: 'important' },
] },
}
const entries = [
makeEntry({ title: 'Yes', isA: 'Note', snippet: 'This is important content' }),
makeEntry({ title: 'Wrong type', isA: 'Project', snippet: 'This is important content' }),
makeEntry({ title: 'No match', isA: 'Note', snippet: 'Regular content' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Yes'])
})
it('body is_empty matches notes with empty snippet', () => {
const view: ViewDefinition = {
name: 'Empty body', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'is_empty' }] },
}
const entries = [
makeEntry({ title: 'Empty', snippet: '' }),
makeEntry({ title: 'Has content', snippet: 'Some text here' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Empty'])
})
})

View File

@@ -29,6 +29,7 @@ function resolveField(entry: VaultEntry, field: string): { scalar?: string | num
if (lower === 'archived') return { scalar: entry.archived }
if (lower === 'trashed') return { scalar: entry.trashed }
if (lower === 'favorite') return { scalar: entry.favorite }
if (lower === 'body') return { scalar: entry.snippet }
// Check relationships first (returns string[])
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === lower)