diff --git a/src/App.tsx b/src/App.tsx index f78a37af..f5ec11af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -376,7 +376,7 @@ function App() { }, [resolvedPath, vault, selection, handleSetSelection]) const availableFields = useMemo(() => { - const builtIn = ['type', 'status', 'title', 'favorite'] + const builtIn = ['type', 'status', 'title', 'favorite', 'body'] if (!vault.entries?.length) return builtIn const customFields = new Set() for (const e of vault.entries) { diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx index 48ebf8d2..5ab50438 100644 --- a/src/components/FilterBuilder.test.tsx +++ b/src/components/FilterBuilder.test.tsx @@ -198,4 +198,16 @@ describe('FilterBuilder wikilink autocomplete', () => { expect(input).toBeInTheDocument() expect(input).not.toHaveAttribute('data-testid', 'filter-value-input') }) + + it('shows body field in field dropdown separated from property fields', () => { + render( + , + ) + // Body field should be selected as the current value + expect(screen.getByText('body')).toBeInTheDocument() + }) }) diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 294d02a5..1bf57f03 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react' import { Plus, X } from '@phosphor-icons/react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types' import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors' import { getTypeIcon } from './NoteItem' @@ -38,12 +38,16 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr return mode === 'all' ? { all: children } : { any: children } } +const CONTENT_FIELDS = new Set(['body']) + function FieldSelect({ value, fields, onChange }: { value: string fields: string[] onChange: (v: string) => void }) { const isCustom = value !== '' && !fields.includes(value) + const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f)) + const contentFields = fields.filter(f => CONTENT_FIELDS.has(f)) return ( ) diff --git a/src/utils/viewFilters.test.ts b/src/utils/viewFilters.test.ts index 17907583..39668eaa 100644 --- a/src/utils/viewFilters.test.ts +++ b/src/utils/viewFilters.test.ts @@ -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']) + }) }) diff --git a/src/utils/viewFilters.ts b/src/utils/viewFilters.ts index ab205299..c75896f3 100644 --- a/src/utils/viewFilters.ts +++ b/src/utils/viewFilters.ts @@ -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)