forked from Archives/Athou_commafeed
migrate filtering expressions to safer CEL and add a query builder
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { Stack } from "@mantine/core"
|
||||
import { MantineValueSelector, QueryBuilderMantine } from "@react-querybuilder/mantine"
|
||||
import {
|
||||
type CombinatorSelectorProps,
|
||||
defaultOperators,
|
||||
defaultRuleProcessorCEL,
|
||||
type Field,
|
||||
type FormatQueryOptions,
|
||||
formatQuery,
|
||||
QueryBuilder,
|
||||
type RuleGroupType,
|
||||
} from "react-querybuilder"
|
||||
import { isCELIdentifier, isCELMember, isCELStringLiteral, parseCEL } from "react-querybuilder/parseCEL"
|
||||
import "react-querybuilder/dist/query-builder.css"
|
||||
|
||||
const fields: Field[] = [
|
||||
{ name: "title", label: "Title" },
|
||||
{ name: "content", label: "Content" },
|
||||
{ name: "url", label: "URL" },
|
||||
{ name: "author", label: "Author" },
|
||||
{ name: "categories", label: "Categories" },
|
||||
{ name: "titleLower", label: "Title (lower case)" },
|
||||
{ name: "contentLower", label: "Content (lower case)" },
|
||||
{ name: "urlLower", label: "URL (lower case)" },
|
||||
{ name: "authorLower", label: "Author (lower case)" },
|
||||
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||
]
|
||||
|
||||
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
|
||||
|
||||
function toCelString(query: RuleGroupType): string {
|
||||
if (query.rules.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const celFormatOptions: FormatQueryOptions = {
|
||||
format: "cel",
|
||||
ruleProcessor: (rule, options, meta) => {
|
||||
if (rule.operator === "matches") {
|
||||
const escapedValue = String(rule.value).replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)
|
||||
return `${rule.field}.matches("${escapedValue}")`
|
||||
}
|
||||
|
||||
return defaultRuleProcessorCEL(rule, options, meta)
|
||||
},
|
||||
}
|
||||
|
||||
return formatQuery(query, celFormatOptions)
|
||||
}
|
||||
|
||||
function fromCelString(celString: string): RuleGroupType {
|
||||
return parseCEL(celString ?? "", {
|
||||
customExpressionHandler: expr => {
|
||||
if (
|
||||
isCELMember(expr) &&
|
||||
expr.right?.value === "matches" &&
|
||||
expr.left &&
|
||||
isCELIdentifier(expr.left) &&
|
||||
expr.list &&
|
||||
isCELStringLiteral(expr.list.value[0])
|
||||
) {
|
||||
return {
|
||||
field: expr.left.value,
|
||||
operator: "matches",
|
||||
value: JSON.parse(expr.list.value[0].value),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getOperators = () => {
|
||||
const filteredDefault = defaultOperators.filter(op => textOperators.has(op.name))
|
||||
return [
|
||||
...filteredDefault,
|
||||
{
|
||||
name: "matches",
|
||||
label: "matches pattern",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function CombinatorSelector(props: Readonly<CombinatorSelectorProps>) {
|
||||
if (props.rules.length === 0) {
|
||||
return null
|
||||
}
|
||||
return <MantineValueSelector {...props} />
|
||||
}
|
||||
|
||||
interface FilteringExpressionEditorProps {
|
||||
initialValue: string | undefined
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function FilteringExpressionEditor({ initialValue, onChange }: Readonly<FilteringExpressionEditorProps>) {
|
||||
const handleQueryChange = (newQuery: RuleGroupType) => {
|
||||
onChange(toCelString(newQuery))
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<QueryBuilderMantine>
|
||||
<QueryBuilder
|
||||
fields={fields}
|
||||
defaultQuery={fromCelString(initialValue ?? "")}
|
||||
onQueryChange={handleQueryChange}
|
||||
getOperators={getOperators}
|
||||
addRuleToNewGroups
|
||||
resetOnFieldChange={false}
|
||||
controlClassnames={{ queryBuilder: "queryBuilder-branches" }}
|
||||
controlElements={{ combinatorSelector: CombinatorSelector }}
|
||||
/>
|
||||
</QueryBuilderMantine>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export function Tree() {
|
||||
expanded={false}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={categoryClicked}
|
||||
/>
|
||||
)
|
||||
@@ -110,6 +111,7 @@ export function Tree() {
|
||||
expanded={false}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={categoryClicked}
|
||||
/>
|
||||
)
|
||||
@@ -118,6 +120,7 @@ export function Tree() {
|
||||
if (!isCategoryDisplayed(category)) return null
|
||||
|
||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||
return (
|
||||
<TreeNode
|
||||
id={category.id}
|
||||
@@ -130,6 +133,7 @@ export function Tree() {
|
||||
expanded={category.expanded}
|
||||
level={level}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onClick={categoryClicked}
|
||||
onIconClick={e => categoryIconClicked(e, category)}
|
||||
key={category.id}
|
||||
@@ -151,6 +155,7 @@ export function Tree() {
|
||||
selected={source.type === "feed" && source.id === String(feed.id)}
|
||||
level={level}
|
||||
hasError={feed.errorCount > errorThreshold}
|
||||
hasWarning={!!feed.filterLegacy}
|
||||
onClick={feedClicked}
|
||||
key={feed.id}
|
||||
/>
|
||||
@@ -168,6 +173,7 @@ export function Tree() {
|
||||
selected={source.type === "tag" && source.id === tag}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={tagClicked}
|
||||
key={tag}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface TreeNodeProps {
|
||||
expanded?: boolean
|
||||
level: number
|
||||
hasError: boolean
|
||||
hasWarning: boolean
|
||||
hasNewEntries: boolean
|
||||
onClick: (e: React.MouseEvent, id: string) => void
|
||||
onIconClick?: (e: React.MouseEvent, id: string) => void
|
||||
@@ -24,15 +25,18 @@ const useStyles = tss
|
||||
.withParams<{
|
||||
selected: boolean
|
||||
hasError: boolean
|
||||
hasWarning: boolean
|
||||
hasUnread: boolean
|
||||
}>()
|
||||
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
|
||||
.create(({ theme, colorScheme, selected, hasError, hasWarning, hasUnread }) => {
|
||||
let backgroundColor = "inherit"
|
||||
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
||||
|
||||
let color: string
|
||||
if (hasError) {
|
||||
color = theme.colors.red[6]
|
||||
} else if (hasWarning) {
|
||||
color = theme.colors.yellow[6]
|
||||
} else if (colorScheme === "dark") {
|
||||
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||
} else {
|
||||
@@ -63,6 +67,7 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
|
||||
const { classes } = useStyles({
|
||||
selected: props.selected,
|
||||
hasError: props.hasError,
|
||||
hasWarning: props.hasWarning,
|
||||
hasUnread: props.unread > 0,
|
||||
})
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user