diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs index 2d1e8c99..5dd048f0 100644 --- a/src-tauri/src/vault/views.rs +++ b/src-tauri/src/vault/views.rs @@ -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(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 { + 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 { + 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, })]), }; diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx index 1d53c244..e73315ec 100644 --- a/src/components/FilterBuilder.test.tsx +++ b/src/components/FilterBuilder.test.tsx @@ -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', () => { diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index eb3a9598..9b89d99a 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -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(['is_empty', 'is_not_empty']) const DATE_OPS = new Set(['before', 'after']) +const REGEX_OPS = new Set(['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 ( - onChange(e.target.value)} - data-testid="filter-value-input" - /> +
+
+ onChange(e.target.value)} + data-testid="filter-value-input" + aria-invalid={invalidRegex || undefined} + /> + {invalidRegex && ( +
+ {regexSupported && ( + + )} +
) } @@ -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 (
onUpdate({ ...condition, op })} + onChange={(op) => onUpdate({ ...condition, op, regex: normalizeRegexFlag(op, regexEnabled) })} /> {!NO_VALUE_OPS.has(condition.op) && ( isDateOp ? onUpdate({ ...condition, value: v })} /> - : onUpdate({ ...condition, value: v })} /> + : ( + onUpdate({ ...condition, value: v })} + regexEnabled={regexEnabled} + regexSupported={regexSupported} + invalidRegex={invalidRegex} + onToggleRegex={() => onUpdate({ ...condition, regex: regexEnabled ? undefined : true })} + /> + ) )}