feat: add regex mode for view filters

This commit is contained in:
lucaronin
2026-04-07 22:51:23 +02:00
parent d33117de40
commit b0b6a4f4cd
6 changed files with 342 additions and 14 deletions

View File

@@ -1,3 +1,4 @@
use regex::RegexBuilder;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
@@ -109,6 +110,8 @@ pub struct FilterCondition {
pub op: FilterOp,
#[serde(default)]
pub value: Option<serde_yaml::Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub regex: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -290,8 +293,36 @@ fn wikilink_stem(link: &str) -> &str {
}
}
fn relationship_candidates(link: &str) -> Vec<String> {
let trimmed = link.trim();
let inner = trimmed
.strip_prefix("[[")
.unwrap_or(trimmed)
.strip_suffix("]]")
.unwrap_or(trimmed);
match inner.split_once('|') {
Some((stem, alias)) => vec![trimmed.to_string(), stem.to_string(), alias.to_string()],
None => vec![trimmed.to_string(), inner.to_string()],
}
}
fn build_regex(pattern: &str) -> Option<regex::Regex> {
RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.ok()
}
fn supports_regex(op: &FilterOp) -> bool {
matches!(
op,
FilterOp::Contains | FilterOp::Equals | FilterOp::NotContains | FilterOp::NotEquals
)
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str();
let mut relationship_values: Option<&[String]> = None;
// Boolean fields
match field {
@@ -316,8 +347,8 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
_ => None,
}
} else if let Some(rels) = entry.relationships.get(field) {
// For relationship fields, handle specially in contains/any_of etc.
return evaluate_relationship_op(&cond.op, rels, &cond.value);
relationship_values = Some(rels);
None
} else {
None
}
@@ -325,6 +356,38 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
};
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
let regex = if cond.regex && supports_regex(&cond.op) {
cond_value.as_deref().and_then(build_regex)
} else {
None
};
if cond.regex && supports_regex(&cond.op) && regex.is_none() {
return false;
}
if let Some(re) = regex.as_ref() {
let matched = if let Some(prop) = field_value.as_deref() {
re.is_match(prop)
} else if let Some(rels) = relationship_values {
rels.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| re.is_match(&candidate))
})
} else {
false
};
return match cond.op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
};
}
if let Some(rels) = relationship_values {
return evaluate_relationship_op(&cond.op, rels, &cond.value);
}
match cond.op {
FilterOp::Equals => match (&field_value, &cond_value) {
@@ -577,6 +640,86 @@ filters:
assert_eq!(result, vec![0]);
}
#[test]
fn test_evaluate_regex_on_scalar_field() {
let yaml = r#"
name: Regex Title
filters:
all:
- field: title
op: contains
value: "^alpha\\s+project$"
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let matching = make_entry(|e| e.title = "Alpha Project".to_string());
let case_matching = make_entry(|e| e.title = "alpha project".to_string());
let non_matching = make_entry(|e| e.title = "Alpha Notes".to_string());
let entries = vec![matching, case_matching, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 1]);
}
#[test]
fn test_evaluate_regex_on_relationship_field() {
let yaml = r#"
name: Regex Relationship
filters:
all:
- field: Related to
op: contains
value: "monday-(112|113)|Monday #112"
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let mut alias_rels = HashMap::new();
alias_rels.insert(
"Related to".to_string(),
vec!["[[monday-112|Monday #112]]".to_string()],
);
let alias_match = make_entry(|e| e.relationships = alias_rels);
let mut stem_rels = HashMap::new();
stem_rels.insert("Related to".to_string(), vec!["[[monday-113]]".to_string()]);
let stem_match = make_entry(|e| e.relationships = stem_rels);
let mut other_rels = HashMap::new();
other_rels.insert(
"Related to".to_string(),
vec!["[[tuesday-200|Tuesday]]".to_string()],
);
let non_matching = make_entry(|e| e.relationships = other_rels);
let entries = vec![alias_match, stem_match, non_matching];
let result = evaluate_view(&def, &entries);
assert_eq!(result, vec![0, 1]);
}
#[test]
fn test_invalid_regex_matches_nothing() {
let yaml = r#"
name: Broken Regex
filters:
all:
- field: title
op: contains
value: "("
regex: true
"#;
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
let entries = vec![
make_entry(|e| e.title = "Alpha Project".to_string()),
make_entry(|e| e.title = "Beta Project".to_string()),
];
let result = evaluate_view(&def, &entries);
assert!(result.is_empty());
}
#[test]
fn test_evaluate_nested_and_or() {
let yaml = r#"
@@ -699,6 +842,7 @@ filters:
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};
@@ -727,6 +871,7 @@ filters:
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};

View File

@@ -29,6 +29,12 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('filter-value-input')).toHaveAttribute('placeholder', 'value')
})
it('renders a regex toggle for supported text operators', () => {
renderBuilder()
expect(screen.getByTestId('filter-regex-toggle')).toBeInTheDocument()
expect(screen.getByTestId('filter-regex-toggle')).toHaveAttribute('aria-pressed', 'false')
})
it('keeps wikilink-style values in the plain text input without opening a dropdown', () => {
renderBuilder({
all: [{ field: 'belongs to', op: 'contains', value: '[[Alpha Project]]' }],
@@ -55,12 +61,35 @@ describe('FilterBuilder value inputs', () => {
)
})
it('toggles regex mode in the emitted filter payload', () => {
renderBuilder()
fireEvent.click(screen.getByTestId('filter-regex-toggle'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'title', op: 'contains', value: '', regex: true }],
}),
)
})
it('shows an invalid-regex indicator when regex mode is enabled with a broken pattern', () => {
renderBuilder({
all: [{ field: 'title', op: 'contains', value: '(', regex: true }],
})
expect(screen.getByTestId('filter-regex-invalid')).toBeInTheDocument()
expect(screen.getByTestId('filter-value-input')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('filter-regex-toggle')).toHaveAttribute('aria-pressed', 'true')
})
it('does not render a value input for empty-check operators', () => {
renderBuilder({
all: [{ field: 'title', op: 'is_empty' }],
})
expect(screen.queryByTestId('filter-value-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('filter-regex-toggle')).not.toBeInTheDocument()
})
it('renders calendar date picker button for date operators', () => {
@@ -80,6 +109,7 @@ describe('FilterBuilder value inputs', () => {
})
expect(screen.getByTestId('date-picker-trigger')).toHaveTextContent('Pick a date')
expect(screen.queryByTestId('filter-regex-toggle')).not.toBeInTheDocument()
})
it('shows body field in field dropdown separated from property fields', () => {

View File

@@ -1,10 +1,11 @@
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
import { format, parseISO } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
const OPERATORS: { value: FilterOp; label: string }[] = [
@@ -20,6 +21,25 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
const REGEX_OPS = new Set<FilterOp>(['contains', 'not_contains', 'equals', 'not_equals'])
function supportsRegex(op: FilterOp): boolean {
return REGEX_OPS.has(op)
}
function normalizeRegexFlag(op: FilterOp, enabled: boolean): boolean | undefined {
return supportsRegex(op) && enabled ? true : undefined
}
function hasInvalidRegex(value: string, regexEnabled: boolean): boolean {
if (!regexEnabled) return false
try {
new RegExp(value, 'i')
return false
} catch {
return true
}
}
function isFilterGroup(node: FilterNode): node is FilterGroup {
return 'all' in node || 'any' in node
@@ -121,18 +141,55 @@ function DateValueInput({ value, onChange }: { value: string; onChange: (v: stri
)
}
function TextValueInput({ value, onChange }: {
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
value: string
onChange: (v: string) => void
regexEnabled: boolean
regexSupported: boolean
invalidRegex: boolean
onToggleRegex: () => void
}) {
return (
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="filter-value-input"
/>
<div className="flex flex-1 min-w-0 items-center gap-1">
<div className="relative min-w-0 flex-1">
<Input
className={cn(
'h-8 w-full min-w-0 text-sm',
invalidRegex && 'border-destructive pr-7 focus-visible:ring-destructive/20',
)}
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="filter-value-input"
aria-invalid={invalidRegex || undefined}
/>
{invalidRegex && (
<WarningCircle
size={14}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-destructive"
data-testid="filter-regex-invalid"
aria-hidden="true"
/>
)}
</div>
{regexSupported && (
<Button
type="button"
variant={regexEnabled ? 'secondary' : 'outline'}
size="sm"
className={cn(
'h-8 shrink-0 px-2 font-mono text-[11px]',
!regexEnabled && 'text-muted-foreground',
)}
onClick={onToggleRegex}
aria-pressed={regexEnabled}
data-testid="filter-regex-toggle"
title="Treat value as regex"
>
.*
</Button>
)}
</div>
)
}
@@ -143,6 +200,9 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
onRemove: () => void
}) {
const isDateOp = DATE_OPS.has(condition.op)
const regexSupported = supportsRegex(condition.op)
const regexEnabled = regexSupported && condition.regex === true
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<FieldSelect
@@ -152,12 +212,21 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
/>
<OperatorSelect
value={condition.op}
onChange={(op) => onUpdate({ ...condition, op })}
onChange={(op) => onUpdate({ ...condition, op, regex: normalizeRegexFlag(op, regexEnabled) })}
/>
{!NO_VALUE_OPS.has(condition.op) && (
isDateOp
? <DateValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
: <TextValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
: (
<TextValueInput
value={String(condition.value ?? '')}
onChange={(v) => onUpdate({ ...condition, value: v })}
regexEnabled={regexEnabled}
regexSupported={regexSupported}
invalidRegex={invalidRegex}
onToggleRegex={() => onUpdate({ ...condition, regex: regexEnabled ? undefined : true })}
/>
)
)}
<Button
type="button"

View File

@@ -205,6 +205,7 @@ export interface FilterCondition {
field: string
op: FilterOp
value?: unknown
regex?: boolean
}
export type FilterGroup = { all: FilterNode[] } | { any: FilterNode[] }

View File

@@ -352,4 +352,45 @@ describe('evaluateView', () => {
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Empty'])
})
it('supports regex matching on scalar fields', () => {
const view: ViewDefinition = {
name: 'Regex title', icon: null, color: null, sort: null,
filters: { all: [{ field: 'title', op: 'contains', value: '^alpha\\s+project$', regex: true }] },
}
const entries = [
makeEntry({ title: 'Alpha Project' }),
makeEntry({ title: 'Alpha Notes' }),
makeEntry({ title: 'alpha project' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Alpha Project', 'alpha project'])
})
it('supports regex matching on relationship aliases and stems', () => {
const view: ViewDefinition = {
name: 'Regex relationship', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: 'monday-(112|113)|Monday #112', regex: true }] },
}
const entries = [
makeEntry({ title: 'Alias match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'Stem match', relationships: { 'belongs to': ['[[monday-113]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[tuesday-200|Tuesday]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Alias match', 'Stem match'])
})
it('treats invalid regex filters as matching nothing', () => {
const view: ViewDefinition = {
name: 'Broken regex', icon: null, color: null, sort: null,
filters: { all: [{ field: 'title', op: 'contains', value: '(', regex: true }] },
}
const entries = [
makeEntry({ title: 'Alpha Project' }),
makeEntry({ title: 'Beta Project' }),
]
const result = evaluateView(view, entries)
expect(result).toEqual([])
})
})

View File

@@ -50,6 +50,18 @@ function wikilinkStem(raw: string): string {
return s.toLowerCase()
}
function relationshipCandidates(raw: string): string[] {
const trimmed = raw.trim()
let inner = trimmed
if (inner.startsWith('[[')) inner = inner.slice(2)
if (inner.endsWith(']]')) inner = inner.slice(0, -2)
const pipe = inner.indexOf('|')
if (pipe >= 0) {
return [trimmed, inner.slice(0, pipe), inner.slice(pipe + 1)]
}
return [trimmed, inner]
}
/** Extract all comparable parts (path and alias) from a wikilink string. */
function wikilinkParts(raw: string): string[] {
let s = raw.trim()
@@ -73,6 +85,20 @@ function toString(v: unknown): string {
return String(v)
}
function compileRegex(cond: FilterCondition, value: string): RegExp | null {
if (cond.regex !== true) return null
try {
return new RegExp(value, 'i')
} catch {
return null
}
}
function usesRegex(cond: FilterCondition): boolean {
return cond.regex === true
&& (cond.op === 'contains' || cond.op === 'not_contains' || cond.op === 'equals' || cond.op === 'not_equals')
}
function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
const resolved = resolveField(entry, cond.field)
const { op, value } = cond
@@ -89,8 +115,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
}
const condVal = toString(value)
const regex = usesRegex(cond) ? compileRegex(cond, condVal) : null
if (usesRegex(cond) && !regex) return false
if (resolved.array) {
if (regex) {
const matched = resolved.array.some((item) => relationshipCandidates(item).some((candidate) => regex.test(candidate)))
if (op === 'contains' || op === 'equals') return matched
if (op === 'not_contains' || op === 'not_equals') return !matched
}
const stem = wikilinkStem(condVal)
const isWikilink = condVal.trim().startsWith('[[')
const arrayMatch = (arr: string[]) => arr.some((item) =>
@@ -111,7 +146,14 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
return false
}
const fieldStr = toString(resolved.scalar).toLowerCase()
const fieldRaw = toString(resolved.scalar)
if (regex) {
const matched = regex.test(fieldRaw)
if (op === 'equals' || op === 'contains') return matched
if (op === 'not_equals' || op === 'not_contains') return !matched
}
const fieldStr = fieldRaw.toLowerCase()
const condStr = condVal.toLowerCase()
if (op === 'equals') return fieldStr === condStr