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::de::{self, MapAccess, Visitor};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -109,6 +110,8 @@ pub struct FilterCondition {
|
|||||||
pub op: FilterOp,
|
pub op: FilterOp,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub value: Option<serde_yaml::Value>,
|
pub value: Option<serde_yaml::Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub regex: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[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 {
|
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||||
let field = cond.field.as_str();
|
let field = cond.field.as_str();
|
||||||
|
let mut relationship_values: Option<&[String]> = None;
|
||||||
|
|
||||||
// Boolean fields
|
// Boolean fields
|
||||||
match field {
|
match field {
|
||||||
@@ -316,8 +347,8 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
} else if let Some(rels) = entry.relationships.get(field) {
|
} else if let Some(rels) = entry.relationships.get(field) {
|
||||||
// For relationship fields, handle specially in contains/any_of etc.
|
relationship_values = Some(rels);
|
||||||
return evaluate_relationship_op(&cond.op, rels, &cond.value);
|
None
|
||||||
} else {
|
} else {
|
||||||
None
|
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 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 {
|
match cond.op {
|
||||||
FilterOp::Equals => match (&field_value, &cond_value) {
|
FilterOp::Equals => match (&field_value, &cond_value) {
|
||||||
@@ -577,6 +640,86 @@ filters:
|
|||||||
assert_eq!(result, vec![0]);
|
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]
|
#[test]
|
||||||
fn test_evaluate_nested_and_or() {
|
fn test_evaluate_nested_and_or() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -699,6 +842,7 @@ filters:
|
|||||||
field: "type".to_string(),
|
field: "type".to_string(),
|
||||||
op: FilterOp::Equals,
|
op: FilterOp::Equals,
|
||||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||||
|
regex: false,
|
||||||
})]),
|
})]),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -727,6 +871,7 @@ filters:
|
|||||||
field: "type".to_string(),
|
field: "type".to_string(),
|
||||||
op: FilterOp::Equals,
|
op: FilterOp::Equals,
|
||||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
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')
|
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', () => {
|
it('keeps wikilink-style values in the plain text input without opening a dropdown', () => {
|
||||||
renderBuilder({
|
renderBuilder({
|
||||||
all: [{ field: 'belongs to', op: 'contains', value: '[[Alpha Project]]' }],
|
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', () => {
|
it('does not render a value input for empty-check operators', () => {
|
||||||
renderBuilder({
|
renderBuilder({
|
||||||
all: [{ field: 'title', op: 'is_empty' }],
|
all: [{ field: 'title', op: 'is_empty' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.queryByTestId('filter-value-input')).not.toBeInTheDocument()
|
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', () => {
|
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.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', () => {
|
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 { format, parseISO } from 'date-fns'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||||
|
|
||||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
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 NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||||
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
|
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 {
|
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||||
return 'all' in node || 'any' in node
|
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
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
|
regexEnabled: boolean
|
||||||
|
regexSupported: boolean
|
||||||
|
invalidRegex: boolean
|
||||||
|
onToggleRegex: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<div className="flex flex-1 min-w-0 items-center gap-1">
|
||||||
className="h-8 flex-1 min-w-0 text-sm"
|
<div className="relative min-w-0 flex-1">
|
||||||
placeholder="value"
|
<Input
|
||||||
value={value}
|
className={cn(
|
||||||
onChange={(e) => onChange(e.target.value)}
|
'h-8 w-full min-w-0 text-sm',
|
||||||
data-testid="filter-value-input"
|
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
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const isDateOp = DATE_OPS.has(condition.op)
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
@@ -152,12 +212,21 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
|||||||
/>
|
/>
|
||||||
<OperatorSelect
|
<OperatorSelect
|
||||||
value={condition.op}
|
value={condition.op}
|
||||||
onChange={(op) => onUpdate({ ...condition, op })}
|
onChange={(op) => onUpdate({ ...condition, op, regex: normalizeRegexFlag(op, regexEnabled) })}
|
||||||
/>
|
/>
|
||||||
{!NO_VALUE_OPS.has(condition.op) && (
|
{!NO_VALUE_OPS.has(condition.op) && (
|
||||||
isDateOp
|
isDateOp
|
||||||
? <DateValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
|
? <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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export interface FilterCondition {
|
|||||||
field: string
|
field: string
|
||||||
op: FilterOp
|
op: FilterOp
|
||||||
value?: unknown
|
value?: unknown
|
||||||
|
regex?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterGroup = { all: FilterNode[] } | { any: FilterNode[] }
|
export type FilterGroup = { all: FilterNode[] } | { any: FilterNode[] }
|
||||||
|
|||||||
@@ -352,4 +352,45 @@ describe('evaluateView', () => {
|
|||||||
const result = evaluateView(view, entries)
|
const result = evaluateView(view, entries)
|
||||||
expect(result.map((e) => e.title)).toEqual(['Empty'])
|
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()
|
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. */
|
/** Extract all comparable parts (path and alias) from a wikilink string. */
|
||||||
function wikilinkParts(raw: string): string[] {
|
function wikilinkParts(raw: string): string[] {
|
||||||
let s = raw.trim()
|
let s = raw.trim()
|
||||||
@@ -73,6 +85,20 @@ function toString(v: unknown): string {
|
|||||||
return String(v)
|
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 {
|
function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||||
const resolved = resolveField(entry, cond.field)
|
const resolved = resolveField(entry, cond.field)
|
||||||
const { op, value } = cond
|
const { op, value } = cond
|
||||||
@@ -89,8 +115,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const condVal = toString(value)
|
const condVal = toString(value)
|
||||||
|
const regex = usesRegex(cond) ? compileRegex(cond, condVal) : null
|
||||||
|
|
||||||
|
if (usesRegex(cond) && !regex) return false
|
||||||
|
|
||||||
if (resolved.array) {
|
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 stem = wikilinkStem(condVal)
|
||||||
const isWikilink = condVal.trim().startsWith('[[')
|
const isWikilink = condVal.trim().startsWith('[[')
|
||||||
const arrayMatch = (arr: string[]) => arr.some((item) =>
|
const arrayMatch = (arr: string[]) => arr.some((item) =>
|
||||||
@@ -111,7 +146,14 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
|||||||
return false
|
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()
|
const condStr = condVal.toLowerCase()
|
||||||
|
|
||||||
if (op === 'equals') return fieldStr === condStr
|
if (op === 'equals') return fieldStr === condStr
|
||||||
|
|||||||
Reference in New Issue
Block a user