From 136f126296af65f50da598d084d1485c0e40437a Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 27 Apr 2025 00:02:20 +0100 Subject: feat: Implement generic rule engine (#1318) * Add schema for the new rule engine * Add rule engine backend logic * Implement the worker logic and event firing * Implement the UI changesfor the rule engine * Ensure that when a referenced list or tag are deleted, the corresponding event/action is * Dont show smart lists in rule engine events * Add privacy validations for attached tag and list ids * Move the rules logic into a models --- .../dashboard/rules/RuleEngineActionBuilder.tsx | 216 ++++++++++++++ .../dashboard/rules/RuleEngineConditionBuilder.tsx | 322 +++++++++++++++++++++ .../dashboard/rules/RuleEngineEventSelector.tsx | 107 +++++++ .../dashboard/rules/RuleEngineRuleEditor.tsx | 203 +++++++++++++ .../dashboard/rules/RuleEngineRuleList.tsx | 166 +++++++++++ 5 files changed, 1014 insertions(+) create mode 100644 apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx create mode 100644 apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx create mode 100644 apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx create mode 100644 apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx create mode 100644 apps/web/components/dashboard/rules/RuleEngineRuleList.tsx (limited to 'apps/web/components/dashboard/rules') diff --git a/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx new file mode 100644 index 00000000..0354e8ac --- /dev/null +++ b/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx @@ -0,0 +1,216 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Archive, + Download, + List, + PlusCircle, + Star, + Tag, + Trash2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { RuleEngineAction } from "@karakeep/shared/types/rules"; + +import { BookmarkListSelector } from "../lists/BookmarkListSelector"; +import { TagAutocomplete } from "../tags/TagAutocomplete"; + +interface ActionBuilderProps { + value: RuleEngineAction[]; + onChange: (actions: RuleEngineAction[]) => void; +} + +export function ActionBuilder({ value, onChange }: ActionBuilderProps) { + const { t } = useTranslation(); + const handleAddAction = () => { + onChange([...value, { type: "addTag", tagId: "" }]); + }; + + const handleRemoveAction = (index: number) => { + const newActions = [...value]; + newActions.splice(index, 1); + onChange(newActions); + }; + + const handleActionTypeChange = ( + index: number, + type: RuleEngineAction["type"], + ) => { + const newActions = [...value]; + + switch (type) { + case "addTag": + newActions[index] = { type: "addTag", tagId: "" }; + break; + case "removeTag": + newActions[index] = { type: "removeTag", tagId: "" }; + break; + case "addToList": + newActions[index] = { type: "addToList", listId: "" }; + break; + case "removeFromList": + newActions[index] = { type: "removeFromList", listId: "" }; + break; + case "downloadFullPageArchive": + newActions[index] = { type: "downloadFullPageArchive" }; + break; + case "favouriteBookmark": + newActions[index] = { type: "favouriteBookmark" }; + break; + case "archiveBookmark": + newActions[index] = { type: "archiveBookmark" }; + break; + default: { + const _exhaustiveCheck: never = type; + return null; + } + } + + onChange(newActions); + }; + + const handleActionFieldChange = ( + index: number, + selectVal: RuleEngineAction, + ) => { + const newActions = [...value]; + newActions[index] = selectVal; + onChange(newActions); + }; + + const renderActionIcon = (type: string) => { + switch (type) { + case "addTag": + case "removeTag": + return ; + case "addToList": + case "removeFromList": + return ; + case "downloadFullPageArchive": + return ; + case "favouriteBookmark": + return ; + case "archiveBookmark": + return ; + default: + return null; + } + }; + + return ( +
+ {value.length === 0 ? ( +
+

No actions added yet

+
+ ) : ( + value.map((action, index) => ( + + +
+
+ {renderActionIcon(action.type)} + + + {(action.type === "addTag" || + action.type === "removeTag") && ( + + handleActionFieldChange(index, { + type: action.type, + tagId: t, + }) + } + /> + )} + + {(action.type === "addToList" || + action.type === "removeFromList") && ( + + handleActionFieldChange(index, { + type: action.type, + listId: e, + }) + } + /> + )} +
+ + +
+
+
+ )) + )} + + +
+ ); +} diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx new file mode 100644 index 00000000..8faca013 --- /dev/null +++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx @@ -0,0 +1,322 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Archive, + ChevronDown, + ChevronRight, + FileType, + Link, + PlusCircle, + Rss, + Star, + Tag, + Trash2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { RuleEngineCondition } from "@karakeep/shared/types/rules"; + +import { FeedSelector } from "../feeds/FeedSelector"; +import { TagAutocomplete } from "../tags/TagAutocomplete"; + +interface ConditionBuilderProps { + value: RuleEngineCondition; + onChange: (condition: RuleEngineCondition) => void; + level?: number; + onRemove?: () => void; +} + +export function ConditionBuilder({ + value, + onChange, + level = 0, + onRemove, +}: ConditionBuilderProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(true); + + const handleTypeChange = (type: RuleEngineCondition["type"]) => { + switch (type) { + case "urlContains": + onChange({ type: "urlContains", str: "" }); + break; + case "importedFromFeed": + onChange({ type: "importedFromFeed", feedId: "" }); + break; + case "bookmarkTypeIs": + onChange({ type: "bookmarkTypeIs", bookmarkType: "link" }); + break; + case "hasTag": + onChange({ type: "hasTag", tagId: "" }); + break; + case "isFavourited": + onChange({ type: "isFavourited" }); + break; + case "isArchived": + onChange({ type: "isArchived" }); + break; + case "and": + onChange({ type: "and", conditions: [] }); + break; + case "or": + onChange({ type: "or", conditions: [] }); + break; + case "alwaysTrue": + onChange({ type: "alwaysTrue" }); + break; + default: { + const _exhaustiveCheck: never = type; + return null; + } + } + }; + + const renderConditionIcon = (type: RuleEngineCondition["type"]) => { + switch (type) { + case "urlContains": + return ; + case "importedFromFeed": + return ; + case "bookmarkTypeIs": + return ; + case "hasTag": + return ; + case "isFavourited": + return ; + case "isArchived": + return ; + default: + return null; + } + }; + + const renderConditionFields = () => { + switch (value.type) { + case "urlContains": + return ( +
+ onChange({ ...value, str: e.target.value })} + placeholder="URL contains..." + className="w-full" + /> +
+ ); + + case "importedFromFeed": + return ( +
+ onChange({ ...value, feedId: e })} + className="w-full" + /> +
+ ); + + case "bookmarkTypeIs": + return ( +
+ +
+ ); + + case "hasTag": + return ( +
+ onChange({ type: value.type, tagId: t })} + /> +
+ ); + + case "and": + case "or": + return ( +
+ {value.conditions.map((condition, index) => ( + { + const newConditions = [...value.conditions]; + newConditions[index] = newCondition; + onChange({ ...value, conditions: newConditions }); + }} + level={level + 1} + onRemove={() => { + const newConditions = [...value.conditions]; + newConditions.splice(index, 1); + onChange({ ...value, conditions: newConditions }); + }} + /> + ))} + + +
+ ); + + default: + return null; + } + }; + + const ConditionSelector = () => ( + + ); + + return ( + + + {value.type === "and" || value.type === "or" ? ( + +
+
+ + + + + + {value.conditions.length} condition + {value.conditions.length !== 1 ? "s" : ""} + +
+ + {onRemove && ( + + )} +
+ + {renderConditionFields()} +
+ ) : ( +
+
+
+ {renderConditionIcon(value.type)} + +
+ + {onRemove && ( + + )} +
+ + {renderConditionFields()} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx b/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx new file mode 100644 index 00000000..ae37945e --- /dev/null +++ b/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx @@ -0,0 +1,107 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslation } from "react-i18next"; + +import type { RuleEngineEvent } from "@karakeep/shared/types/rules"; + +import { BookmarkListSelector } from "../lists/BookmarkListSelector"; +import { TagAutocomplete } from "../tags/TagAutocomplete"; + +interface EventSelectorProps { + value: RuleEngineEvent; + onChange: (event: RuleEngineEvent) => void; +} + +export function EventSelector({ value, onChange }: EventSelectorProps) { + const { t } = useTranslation(); + const handleTypeChange = (type: RuleEngineEvent["type"]) => { + switch (type) { + case "bookmarkAdded": + onChange({ type: "bookmarkAdded" }); + break; + case "tagAdded": + onChange({ type: "tagAdded", tagId: "" }); + break; + case "tagRemoved": + onChange({ type: "tagRemoved", tagId: "" }); + break; + case "addedToList": + onChange({ type: "addedToList", listId: "" }); + break; + case "removedFromList": + onChange({ type: "removedFromList", listId: "" }); + break; + case "favourited": + onChange({ type: "favourited" }); + break; + case "archived": + onChange({ type: "archived" }); + break; + default: { + const _exhaustiveCheck: never = type; + return null; + } + } + }; + + return ( + + +
+ + + {/* Additional fields based on event type */} + {(value.type === "tagAdded" || value.type === "tagRemoved") && ( + onChange({ type: value.type, tagId: t ?? "" })} + /> + )} + + {(value.type === "addedToList" || + value.type === "removedFromList") && ( + onChange({ type: value.type, listId: l })} + /> + )} +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx new file mode 100644 index 00000000..da10317a --- /dev/null +++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx @@ -0,0 +1,203 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import { ActionBuilder } from "@/components/dashboard/rules/RuleEngineActionBuilder"; +import { ConditionBuilder } from "@/components/dashboard/rules/RuleEngineConditionBuilder"; +import { EventSelector } from "@/components/dashboard/rules/RuleEngineEventSelector"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "@/components/ui/use-toast"; +import { Save, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; +import { + useCreateRule, + useUpdateRule, +} from "@karakeep/shared-react/hooks/rules"; + +interface RuleEditorProps { + rule: Omit & { id: string | null }; + onCancel: () => void; +} + +export function RuleEditor({ rule, onCancel }: RuleEditorProps) { + const { t } = useTranslation(); + const { mutate: createRule, isPending: isCreating } = useCreateRule({ + onSuccess: () => { + toast({ + description: t("settings.rules.rule_has_been_created"), + }); + onCancel(); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + if (e.data.zodError) { + toast({ + variant: "destructive", + description: Object.values(e.data.zodError.fieldErrors) + .flat() + .join("\n"), + }); + } else { + toast({ + variant: "destructive", + description: e.message, + }); + } + } else { + toast({ + variant: "destructive", + title: t("common.something_went_wrong"), + }); + } + }, + }); + const { mutate: updateRule, isPending: isUpdating } = useUpdateRule({ + onSuccess: () => { + toast({ + description: t("settings.rules.rule_has_been_updated"), + }); + onCancel(); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + if (e.data.zodError) { + toast({ + variant: "destructive", + description: Object.values(e.data.zodError.fieldErrors) + .flat() + .join("\n"), + }); + } else { + toast({ + variant: "destructive", + description: e.message, + }); + } + } else { + toast({ + variant: "destructive", + title: t("common.something_went_wrong"), + }); + } + }, + }); + + const [editedRule, setEditedRule] = useState({ ...rule }); + + useEffect(() => { + setEditedRule({ ...rule }); + }, [rule]); + + const handleEventChange = (event: RuleEngineEvent) => { + setEditedRule({ ...editedRule, event }); + }; + + const handleConditionChange = (condition: RuleEngineCondition) => { + setEditedRule({ ...editedRule, condition }); + }; + + const handleActionsChange = (actions: RuleEngineAction[]) => { + setEditedRule({ ...editedRule, actions }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const rule = editedRule; + if (rule.id) { + updateRule({ + ...rule, + id: rule.id, + }); + } else { + createRule(rule); + } + }; + + return ( +
+ + + + {rule.id + ? t("settings.rules.edit_rule") + : t("settings.rules.ceate_rule")} + + + +
+
+ + + setEditedRule({ ...editedRule, name: e.target.value }) + } + placeholder={t("settings.rules.enter_rule_name")} + required + /> +
+ +
+ +