feat: add regex mode for view filters
This commit is contained in:
@@ -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,
|
||||
})]),
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -205,6 +205,7 @@ export interface FilterCondition {
|
||||
field: string
|
||||
op: FilterOp
|
||||
value?: unknown
|
||||
regex?: boolean
|
||||
}
|
||||
|
||||
export type FilterGroup = { all: FilterNode[] } | { any: FilterNode[] }
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user