diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-04-27 00:02:20 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-27 00:02:20 +0100 |
| commit | 136f126296af65f50da598d084d1485c0e40437a (patch) | |
| tree | 2725c7932ebbcb9b48b5af98eb9b72329a400260 /apps | |
| parent | ca47be7fe7be128f459c37614a04902a873fe289 (diff) | |
| download | karakeep-136f126296af65f50da598d084d1485c0e40437a.tar.zst | |
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
Diffstat (limited to 'apps')
22 files changed, 1506 insertions, 24 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6b75edf3..beeecc2b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -49,7 +49,11 @@ export default async function RootLayout({ const userSettings = await getUserLocalSettings(); const isRTL = userSettings.lang === "ar"; return ( - <html lang={userSettings.lang} dir={isRTL ? "rtl" : "ltr"}> + <html + className="sm:overflow-hidden" + lang={userSettings.lang} + dir={isRTL ? "rtl" : "ltr"} + > <body className={inter.className}> <Providers session={session} diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 62ac041c..9bac783c 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -5,6 +5,7 @@ import { TFunction } from "i18next"; import { ArrowLeft, Download, + GitBranch, Image, KeyRound, Link, @@ -62,6 +63,11 @@ const settingsSidebarItems = ( path: "/settings/webhooks", }, { + name: t("settings.rules.rules"), + icon: <GitBranch size={18} />, + path: "/settings/rules", + }, + { name: t("settings.manage_assets.manage_assets"), icon: <Image size={18} />, path: "/settings/assets", diff --git a/apps/web/app/settings/rules/page.tsx b/apps/web/app/settings/rules/page.tsx new file mode 100644 index 00000000..98a30bcc --- /dev/null +++ b/apps/web/app/settings/rules/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { RuleEditor } from "@/components/dashboard/rules/RuleEngineRuleEditor"; +import RuleList from "@/components/dashboard/rules/RuleEngineRuleList"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip"; +import { FlaskConical, PlusCircle } from "lucide-react"; + +import { RuleEngineRule } from "@karakeep/shared/types/rules"; + +export default function RulesSettingsPage() { + const { t } = useTranslation(); + const [editingRule, setEditingRule] = useState< + (Omit<RuleEngineRule, "id"> & { id: string | null }) | null + >(null); + + const { data: rules, isLoading } = api.rules.list.useQuery(undefined, { + refetchOnWindowFocus: true, + refetchOnMount: true, + }); + + const handleCreateRule = () => { + const newRule = { + id: null, + name: "New Rule", + description: "Description of the new rule", + enabled: true, + event: { type: "bookmarkAdded" as const }, + condition: { type: "alwaysTrue" as const }, + actions: [{ type: "addTag" as const, tagId: "" }], + }; + setEditingRule(newRule); + }; + + const handleDeleteRule = (ruleId: string) => { + if (editingRule?.id === ruleId) { + // If the rule being edited is being deleted, reset the editing rule + setEditingRule(null); + } + }; + + return ( + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <span className="flex items-center gap-2 text-lg font-medium"> + {t("settings.rules.rules")} + <Tooltip> + <TooltipTrigger className="text-muted-foreground"> + <FlaskConical size={15} /> + </TooltipTrigger> + <TooltipContent side="bottom"> + {t("common.experimental")} + </TooltipContent> + </Tooltip> + </span> + <Button onClick={handleCreateRule} variant="default"> + <PlusCircle className="mr-2 h-4 w-4" /> + {t("settings.rules.ceate_rule")} + </Button> + </div> + <p className="text-sm italic text-muted-foreground"> + {t("settings.rules.description")} + </p> + {!rules || isLoading ? ( + <FullPageSpinner /> + ) : ( + <RuleList + rules={rules.rules} + onEditRule={(r) => setEditingRule(r)} + onDeleteRule={handleDeleteRule} + /> + )} + <div className="lg:col-span-7"> + {editingRule && ( + <RuleEditor + rule={editingRule} + onCancel={() => setEditingRule(null)} + /> + )} + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx new file mode 100644 index 00000000..db95a042 --- /dev/null +++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx @@ -0,0 +1,53 @@ +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import LoadingSpinner from "@/components/ui/spinner"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +export function FeedSelector({ + value, + onChange, + placeholder = "Select a feed", + className, +}: { + className?: string; + value?: string | null; + onChange: (value: string) => void; + placeholder?: string; +}) { + const { data, isPending } = api.feeds.list.useQuery(undefined, { + select: (data) => data.feeds, + }); + + if (isPending) { + return <LoadingSpinner />; + } + + return ( + <Select onValueChange={onChange} value={value ?? ""}> + <SelectTrigger className={cn("w-full", className)}> + <SelectValue placeholder={placeholder} /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {data?.map((f) => ( + <SelectItem key={f.id} value={f.id}> + {f.name} + </SelectItem> + ))} + {(data ?? []).length == 0 && ( + <SelectItem value="nofeed" disabled> + You don't currently have any feeds. + </SelectItem> + )} + </SelectGroup> + </SelectContent> + </Select> + ); +} diff --git a/apps/web/components/dashboard/lists/BookmarkListSelector.tsx b/apps/web/components/dashboard/lists/BookmarkListSelector.tsx index db37efc0..9ce56031 100644 --- a/apps/web/components/dashboard/lists/BookmarkListSelector.tsx +++ b/apps/web/components/dashboard/lists/BookmarkListSelector.tsx @@ -7,8 +7,10 @@ import { SelectValue, } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { ZBookmarkList } from "@karakeep/shared/types/lists"; export function BookmarkListSelector({ value, @@ -16,12 +18,16 @@ export function BookmarkListSelector({ hideSubtreeOf, hideBookmarkIds = [], placeholder = "Select a list", + className, + listTypes = ["manual", "smart"], }: { + className?: string; value?: string | null; onChange: (value: string) => void; placeholder?: string; hideSubtreeOf?: string; hideBookmarkIds?: string[]; + listTypes?: ZBookmarkList["type"][]; }) { const { data, isPending: isFetchingListsPending } = useBookmarkLists(); let { allPaths } = data ?? {}; @@ -34,6 +40,9 @@ export function BookmarkListSelector({ if (hideBookmarkIds.includes(path[path.length - 1].id)) { return false; } + if (!listTypes.includes(path[path.length - 1].type)) { + return false; + } if (!hideSubtreeOf) { return true; } @@ -42,7 +51,7 @@ export function BookmarkListSelector({ return ( <Select onValueChange={onChange} value={value ?? ""}> - <SelectTrigger className="w-full"> + <SelectTrigger className={cn("w-full", className)}> <SelectValue placeholder={placeholder} /> </SelectTrigger> <SelectContent> 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 <Tag className="h-4 w-4" />; + case "addToList": + case "removeFromList": + return <List className="h-4 w-4" />; + case "downloadFullPageArchive": + return <Download className="h-4 w-4" />; + case "favouriteBookmark": + return <Star className="h-4 w-4" />; + case "archiveBookmark": + return <Archive className="h-4 w-4" />; + default: + return null; + } + }; + + return ( + <div className="space-y-3"> + {value.length === 0 ? ( + <div className="rounded-md border border-dashed p-4 text-center"> + <p className="text-sm text-muted-foreground">No actions added yet</p> + </div> + ) : ( + value.map((action, index) => ( + <Card key={index}> + <CardContent className="p-3"> + <div className="flex items-center justify-between"> + <div className="flex flex-1 items-center"> + {renderActionIcon(action.type)} + <Select + value={action.type} + onValueChange={(value) => + handleActionTypeChange( + index, + value as RuleEngineAction["type"], + ) + } + > + <SelectTrigger className="ml-2 h-8 w-auto border-none bg-transparent px-2"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="addTag"> + {t("settings.rules.actions_types.add_tag")} + </SelectItem> + <SelectItem value="removeTag"> + {t("settings.rules.actions_types.remove_tag")} + </SelectItem> + <SelectItem value="addToList"> + {t("settings.rules.actions_types.add_to_list")} + </SelectItem> + <SelectItem value="removeFromList"> + {t("settings.rules.actions_types.remove_from_list")} + </SelectItem> + <SelectItem value="downloadFullPageArchive"> + {t( + "settings.rules.actions_types.download_full_page_archive", + )} + </SelectItem> + <SelectItem value="favouriteBookmark"> + {t("settings.rules.actions_types.favourite_bookmark")} + </SelectItem> + <SelectItem value="archiveBookmark"> + {t("settings.rules.actions_types.archive_bookmark")} + </SelectItem> + </SelectContent> + </Select> + + {(action.type === "addTag" || + action.type === "removeTag") && ( + <TagAutocomplete + className="ml-2 h-8 flex-1" + tagId={action.tagId} + onChange={(t) => + handleActionFieldChange(index, { + type: action.type, + tagId: t, + }) + } + /> + )} + + {(action.type === "addToList" || + action.type === "removeFromList") && ( + <BookmarkListSelector + className="ml-2 h-8 flex-1" + value={action.listId} + listTypes={["manual"]} + onChange={(e) => + handleActionFieldChange(index, { + type: action.type, + listId: e, + }) + } + /> + )} + </div> + + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveAction(index)} + className="h-7 w-7 p-0" + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + </div> + </CardContent> + </Card> + )) + )} + + <Button + type="button" + variant="outline" + size="sm" + onClick={handleAddAction} + className="w-full" + > + <PlusCircle className="mr-2 h-4 w-4" /> + Add Action + </Button> + </div> + ); +} 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 <Link className="h-4 w-4" />; + case "importedFromFeed": + return <Rss className="h-4 w-4" />; + case "bookmarkTypeIs": + return <FileType className="h-4 w-4" />; + case "hasTag": + return <Tag className="h-4 w-4" />; + case "isFavourited": + return <Star className="h-4 w-4" />; + case "isArchived": + return <Archive className="h-4 w-4" />; + default: + return null; + } + }; + + const renderConditionFields = () => { + switch (value.type) { + case "urlContains": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="URL contains..." + className="w-full" + /> + </div> + ); + + case "importedFromFeed": + return ( + <div className="mt-2"> + <FeedSelector + value={value.feedId} + onChange={(e) => onChange({ ...value, feedId: e })} + className="w-full" + /> + </div> + ); + + case "bookmarkTypeIs": + return ( + <div className="mt-2"> + <Select + value={value.bookmarkType} + onValueChange={(bookmarkType) => + onChange({ + ...value, + bookmarkType: bookmarkType as "link" | "text" | "asset", + }) + } + > + <SelectTrigger> + <SelectValue placeholder="Select bookmark type" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="link"> + {t("common.bookmark_types.link")} + </SelectItem> + <SelectItem value="text"> + {t("common.bookmark_types.text")} + </SelectItem> + <SelectItem value="asset"> + {t("common.bookmark_types.media")} + </SelectItem> + </SelectContent> + </Select> + </div> + ); + + case "hasTag": + return ( + <div className="mt-2"> + <TagAutocomplete + tagId={value.tagId} + onChange={(t) => onChange({ type: value.type, tagId: t })} + /> + </div> + ); + + case "and": + case "or": + return ( + <div className="mt-2 space-y-2"> + {value.conditions.map((condition, index) => ( + <ConditionBuilder + key={index} + value={condition} + onChange={(newCondition) => { + 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 }); + }} + /> + ))} + + <Button + type="button" + variant="outline" + size="sm" + className="mt-2" + onClick={() => { + onChange({ + ...value, + conditions: [ + ...value.conditions, + { type: "urlContains", str: "" }, + ], + }); + }} + > + <PlusCircle className="mr-2 h-4 w-4" /> + Add Condition + </Button> + </div> + ); + + default: + return null; + } + }; + + const ConditionSelector = () => ( + <Select value={value.type} onValueChange={handleTypeChange}> + <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="alwaysTrue"> + {t("settings.rules.conditions_types.always")} + </SelectItem> + <SelectItem value="and"> + {t("settings.rules.conditions_types.and")} + </SelectItem> + <SelectItem value="or"> + {t("settings.rules.conditions_types.or")} + </SelectItem> + <SelectItem value="urlContains"> + {t("settings.rules.conditions_types.url_contains")} + </SelectItem> + <SelectItem value="importedFromFeed"> + {t("settings.rules.conditions_types.imported_from_feed")} + </SelectItem> + <SelectItem value="bookmarkTypeIs"> + {t("settings.rules.conditions_types.bookmark_type_is")} + </SelectItem> + <SelectItem value="hasTag"> + {t("settings.rules.conditions_types.has_tag")} + </SelectItem> + <SelectItem value="isFavourited"> + {t("settings.rules.conditions_types.is_favourited")} + </SelectItem> + <SelectItem value="isArchived"> + {t("settings.rules.conditions_types.is_archived")} + </SelectItem> + </SelectContent> + </Select> + ); + + return ( + <Card + className={`border-l-4 ${value.type === "and" ? "border-l-emerald-500" : value.type === "or" ? "border-l-amber-500" : "border-l-slate-300"}`} + > + <CardContent className="p-3"> + {value.type === "and" || value.type === "or" ? ( + <Collapsible open={isOpen} onOpenChange={setIsOpen}> + <div className="flex items-center justify-between"> + <div className="flex items-center"> + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm" className="h-7 w-7 p-1"> + {isOpen ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </Button> + </CollapsibleTrigger> + <ConditionSelector /> + <span className="ml-1 text-sm text-muted-foreground"> + {value.conditions.length} condition + {value.conditions.length !== 1 ? "s" : ""} + </span> + </div> + + {onRemove && ( + <Button + variant="ghost" + size="sm" + onClick={onRemove} + className="h-7 w-7 p-0" + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + )} + </div> + + <CollapsibleContent>{renderConditionFields()}</CollapsibleContent> + </Collapsible> + ) : ( + <div> + <div className="flex items-center justify-between"> + <div className="flex items-center"> + {renderConditionIcon(value.type)} + <ConditionSelector /> + </div> + + {onRemove && ( + <Button + variant="ghost" + size="sm" + onClick={onRemove} + className="h-7 w-7 p-0" + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + )} + </div> + + {renderConditionFields()} + </div> + )} + </CardContent> + </Card> + ); +} 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 ( + <Card> + <CardContent className="p-4"> + <div className="flex gap-4"> + <Select value={value.type} onValueChange={handleTypeChange}> + <SelectTrigger id="event-type"> + <SelectValue placeholder="Select event type" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="bookmarkAdded"> + {t("settings.rules.events_types.bookmark_added")} + </SelectItem> + <SelectItem value="tagAdded"> + {t("settings.rules.events_types.tag_added")} + </SelectItem> + <SelectItem value="tagRemoved"> + {t("settings.rules.events_types.tag_removed")} + </SelectItem> + <SelectItem value="addedToList"> + {t("settings.rules.events_types.added_to_list")} + </SelectItem> + <SelectItem value="removedFromList"> + {t("settings.rules.events_types.removed_from_list")} + </SelectItem> + <SelectItem value="favourited"> + {t("settings.rules.events_types.favourited")} + </SelectItem> + <SelectItem value="archived"> + {t("settings.rules.events_types.archived")} + </SelectItem> + </SelectContent> + </Select> + + {/* Additional fields based on event type */} + {(value.type === "tagAdded" || value.type === "tagRemoved") && ( + <TagAutocomplete + className="w-full" + tagId={value.tagId} + onChange={(t) => onChange({ type: value.type, tagId: t ?? "" })} + /> + )} + + {(value.type === "addedToList" || + value.type === "removedFromList") && ( + <BookmarkListSelector + listTypes={["manual"]} + value={value.listId} + onChange={(l) => onChange({ type: value.type, listId: l })} + /> + )} + </div> + </CardContent> + </Card> + ); +} 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<RuleEngineRule, "id"> & { 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<typeof rule>({ ...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 ( + <form onSubmit={handleSubmit}> + <Card> + <CardHeader> + <CardTitle> + {rule.id + ? t("settings.rules.edit_rule") + : t("settings.rules.ceate_rule")} + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="space-y-4"> + <div> + <Label htmlFor="name">{t("settings.rules.rule_name")}</Label> + <Input + id="name" + value={editedRule.name} + onChange={(e) => + setEditedRule({ ...editedRule, name: e.target.value }) + } + placeholder={t("settings.rules.enter_rule_name")} + required + /> + </div> + + <div> + <Label htmlFor="description">Description</Label> + <Textarea + id="description" + value={editedRule.description ?? ""} + onChange={(e) => + setEditedRule({ ...editedRule, description: e.target.value }) + } + placeholder={t("settings.rules.describe_what_this_rule_does")} + rows={2} + /> + </div> + </div> + + <div className="space-y-2"> + <Label>{t("settings.rules.whenever")}</Label> + <EventSelector + value={editedRule.event} + onChange={handleEventChange} + /> + </div> + + <div className="space-y-2"> + <Label>{t("settings.rules.if")}</Label> + <ConditionBuilder + value={editedRule.condition} + onChange={handleConditionChange} + /> + </div> + + <div className="space-y-2"> + <Label>{t("common.actions")}</Label> + <ActionBuilder + value={editedRule.actions} + onChange={handleActionsChange} + /> + </div> + + <div className="flex justify-end space-x-2 pt-4"> + <Button type="button" variant="outline" onClick={onCancel}> + <X className="mr-2 h-4 w-4" /> + {t("actions.cancel")} + </Button> + <ActionButton loading={isCreating || isUpdating} type="submit"> + <Save className="mr-2 h-4 w-4" /> + {t("settings.rules.save_rule")} + </ActionButton> + </div> + </CardContent> + </Card> + </form> + ); +} diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx new file mode 100644 index 00000000..206a3550 --- /dev/null +++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx @@ -0,0 +1,166 @@ +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/components/ui/use-toast"; +import { useClientConfig } from "@/lib/clientConfig"; +import { Edit, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { RuleEngineRule } from "@karakeep/shared/types/rules"; +import { + useDeleteRule, + useUpdateRule, +} from "@karakeep/shared-react/hooks/rules"; + +export default function RuleList({ + rules, + onEditRule, + onDeleteRule, +}: { + rules: RuleEngineRule[]; + onEditRule: (rule: RuleEngineRule) => void; + onDeleteRule?: (ruleId: string) => void; +}) { + const { t } = useTranslation(); + const { demoMode } = useClientConfig(); + const { mutate: updateRule, isPending: isUpdating } = useUpdateRule({ + onSuccess: () => { + toast({ + description: t("settings.rules.rule_has_been_updated"), + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const { mutate: deleteRule, isPending: isDeleting } = useDeleteRule({ + onSuccess: (_ret, req) => { + toast({ + description: t("settings.rules.rule_has_been_deleted"), + }); + onDeleteRule?.(req.id); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + if (rules.length === 0) { + return ( + <div className="rounded-lg border border-dashed p-8 text-center"> + <h3 className="text-lg font-medium"> + {t("settings.rules.no_rules_created_yet")} + </h3> + <p className="mt-2 text-sm text-muted-foreground"> + {t("settings.rules.create_your_first_rule")} + </p> + </div> + ); + } + + return ( + <div className="space-y-4"> + {rules.map((rule) => ( + <Card key={rule.id} className={rule.enabled ? "" : "opacity-70"}> + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="mr-4 flex-1"> + <h3 className="font-medium">{rule.name}</h3> + <p className="line-clamp-2 text-sm text-muted-foreground"> + {rule.description} + </p> + <div className="mt-2 flex items-center text-xs text-muted-foreground"> + <span className="flex items-center"> + <span className="mr-1">Trigger:</span> + <span className="font-medium"> + {formatEventType(rule.event.type)} + </span> + </span> + <span className="mx-2">•</span> + <span> + {rule.actions.length} action + {rule.actions.length !== 1 ? "s" : ""} + </span> + </div> + </div> + <div className="flex items-center space-x-2"> + <Switch + disabled={!!demoMode || isUpdating} + checked={rule.enabled} + onCheckedChange={(checked) => + updateRule({ + ...rule, + enabled: checked, + }) + } + /> + <Button + variant="ghost" + size="icon" + onClick={() => onEditRule(rule)} + > + <Edit className="h-4 w-4" /> + </Button> + <ActionConfirmingDialog + title={t("settings.rules.delete_rule")} + description={t("settings.rules.delete_rule_confirmation")} + actionButton={(setDialogOpen) => ( + <ActionButton + loading={isDeleting} + variant="destructive" + onClick={() => { + deleteRule({ id: rule.id }); + setDialogOpen(true); + }} + > + <Trash2 className="mr-2 h-4 w-4" /> + {t("actions.delete")} + </ActionButton> + )} + > + <Button + variant="ghost" + size="icon" + className="text-red-500 hover:text-red-600" + > + <Trash2 className="h-4 w-4" /> + </Button> + </ActionConfirmingDialog> + </div> + </div> + </CardContent> + </Card> + ))} + </div> + ); +} + +function formatEventType(type: string): string { + switch (type) { + case "bookmarkAdded": + return "Bookmark Added"; + case "tagAdded": + return "Tag Added"; + case "tagRemoved": + return "Tag Removed"; + case "addedToList": + return "Added to List"; + case "removedFromList": + return "Removed from List"; + case "favourited": + return "Favourited"; + case "archived": + return "Archived"; + default: + return type; + } +} diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx new file mode 100644 index 00000000..23054bc7 --- /dev/null +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import LoadingSpinner from "@/components/ui/spinner"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsUpDown, X } from "lucide-react"; + +interface TagAutocompleteProps { + tagId: string; + onChange?: (value: string) => void; + className?: string; +} + +export function TagAutocomplete({ + tagId, + onChange, + className, +}: TagAutocompleteProps) { + const { data: tags, isPending } = api.tags.list.useQuery(undefined, { + select: (data) => data.tags, + }); + + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + // Filter tags based on search query + const filteredTags = (tags ?? []) + .filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase())) + .slice(0, 10); // Only show first 10 matches for performance + + const handleSelect = (currentValue: string) => { + setOpen(false); + onChange?.(currentValue); + }; + + const clearSelection = () => { + onChange?.(""); + }; + + const selectedTag = React.useMemo(() => { + if (!tagId) return null; + return tags?.find((t) => t.id === tagId) ?? null; + }, [tags, tagId]); + + if (isPending) { + return <LoadingSpinner />; + } + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className={cn("justify-between", className)} + > + {selectedTag ? ( + <div className="flex w-full items-center justify-between"> + <span>{selectedTag.name}</span> + <X + className="h-4 w-4 shrink-0 cursor-pointer opacity-50 hover:opacity-100" + onClick={(e) => { + e.stopPropagation(); + clearSelection(); + }} + /> + </div> + ) : ( + "Select a tag..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[--radix-popover-trigger-width] p-0"> + <Command shouldFilter={false}> + <CommandInput + placeholder="Search tags..." + value={searchQuery} + onValueChange={setSearchQuery} + className={cn("h-9", className)} + /> + <CommandList> + <CommandEmpty>No tags found.</CommandEmpty> + <CommandGroup className="max-h-60 overflow-y-auto"> + {filteredTags.map((tag) => ( + <CommandItem + key={tag.id} + value={tag.id} + onSelect={handleSelect} + className="cursor-pointer" + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedTag?.id === tag.id ? "opacity-100" : "opacity-0", + )} + /> + {tag.name} + </CommandItem> + ))} + {searchQuery && filteredTags.length >= 10 && ( + <div className="px-2 py-2 text-center text-xs text-muted-foreground"> + Showing first 10 results. Keep typing to refine your search. + </div> + )} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/dashboard/tags/TagSelector.tsx b/apps/web/components/dashboard/tags/TagSelector.tsx index afc7340b..01690da1 100644 --- a/apps/web/components/dashboard/tags/TagSelector.tsx +++ b/apps/web/components/dashboard/tags/TagSelector.tsx @@ -8,15 +8,18 @@ import { } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; export function TagSelector({ value, onChange, placeholder = "Select a tag", + className, }: { value?: string | null; onChange: (value: string) => void; placeholder?: string; + className?: string; }) { const { data: allTags, isPending } = api.tags.list.useQuery(); @@ -28,7 +31,7 @@ export function TagSelector({ return ( <Select onValueChange={onChange} value={value ?? ""}> - <SelectTrigger className="w-full"> + <SelectTrigger className={cn("w-full", className)}> <SelectValue placeholder={placeholder} /> </SelectTrigger> <SelectContent> diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index 00e70d3f..b41701ba 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -30,6 +30,7 @@ import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -135,7 +136,10 @@ export default function AddApiKey() { return ( <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <DialogTrigger asChild> - <Button>{t("settings.api_keys.new_api_key")}</Button> + <Button> + <PlusCircle className="mr-2 h-4 w-4" /> + {t("settings.api_keys.new_api_key")} + </Button> </DialogTrigger> <DialogContent> <DialogHeader> diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index f5e72372..ff8590c9 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -22,6 +22,7 @@ import { ArrowDownToLine, CheckCircle, CircleDashed, + CirclePlus, Edit, FlaskConical, Plus, @@ -93,7 +94,7 @@ export function FeedsEditorDialog() { <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button> - <Plus className="mr-2 size-4" /> + <CirclePlus className="mr-2 size-4" /> {t("settings.feeds.add_a_subscription")} </Button> </DialogTrigger> diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx index 05ca0615..8efd3ba6 100644 --- a/apps/web/components/settings/WebhookSettings.tsx +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -16,7 +16,15 @@ import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Edit, KeyRound, Plus, Save, Trash2, X } from "lucide-react"; +import { + Edit, + KeyRound, + Plus, + PlusCircle, + Save, + Trash2, + X, +} from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -82,7 +90,7 @@ export function WebhooksEditorDialog() { <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button> - <Plus className="mr-2 size-4" /> + <PlusCircle className="mr-2 size-4" /> {t("settings.webhooks.create_webhook")} </Button> </DialogTrigger> diff --git a/apps/web/components/shared/sidebar/MobileSidebar.tsx b/apps/web/components/shared/sidebar/MobileSidebar.tsx index d3edc7df..15285a9e 100644 --- a/apps/web/components/shared/sidebar/MobileSidebar.tsx +++ b/apps/web/components/shared/sidebar/MobileSidebar.tsx @@ -11,7 +11,7 @@ export default async function MobileSidebar({ }) { const { t } = await useTranslation(); return ( - <aside className="w-full"> + <aside className="w-full overflow-x-auto"> <ul className="flex justify-between space-x-2 border-b-black px-5 py-2 pt-5"> {items(t).map((item) => ( <MobileSidebarItem diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index f6a59683..71dc93ef 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -167,6 +167,54 @@ "asset_link": "Asset Link", "delete_asset": "Delete Asset", "delete_asset_confirmation": "Are you sure you want to delete this asset?" + }, + "rules": { + "rules": "Rule Engine", + "rule_name": "Rule Name", + "description": "You can use rules to trigger actions when an event fires.", + "ceate_rule": "Create Rule", + "edit_rule": "Edit Rule", + "save_rule": "Save Rule", + "delete_rule": "Delete Rule", + "delete_rule_confirmation": "Are you sure you want to delete this rule?", + "whenever": "Whenever ...", + "if": "If ...", + "enter_rule_name": "Enter rule name", + "describe_what_this_rule_does": "Describe what this rule does", + "rule_has_been_created": "Rule has been created!", + "rule_has_been_updated": "Rule has been updated!", + "rule_has_been_deleted": "Rule has been deleted!", + "no_rules_created_yet": "No rules created yet", + "create_your_first_rule": "Create your first rule to automate your workflow", + "conditions_types": { + "always": "Always", + "url_contains": "URL Contains", + "imported_from_feed": "Imported From Feed", + "bookmark_type_is": "Bookmark Type Is", + "has_tag": "Has Tag", + "is_favourited": "Is Favourited", + "is_archived": "Is Archived", + "and": "All of the following are true", + "or": "Any of the following are true" + }, + "actions_types": { + "add_tag": "Add Tag", + "remove_tag": "Remove Tag", + "add_to_list": "Add to List", + "remove_from_list": "Remove from List", + "download_full_page_archive": "Download Full Page Archive", + "favourite_bookmark": "Favourite Bookmark", + "archive_bookmark": "Archive Bookmark" + }, + "events_types": { + "bookmark_added": "A bookmark is added", + "tag_added": "This tag is added to a bookmark", + "tag_removed": "This tag is removed from a bookmark", + "added_to_list": "A bookmark is added to this list", + "removed_from_list": "A bookmark is removed from this list", + "favourited": "A bookmark is favourited", + "archived": "A bookmark is archived" + } } }, "admin": { diff --git a/apps/workers/index.ts b/apps/workers/index.ts index 207c7f64..208666c7 100644 --- a/apps/workers/index.ts +++ b/apps/workers/index.ts @@ -2,6 +2,7 @@ import "dotenv/config"; import { AssetPreprocessingWorker } from "assetPreprocessingWorker"; import { FeedRefreshingWorker, FeedWorker } from "feedWorker"; +import { RuleEngineWorker } from "ruleEngineWorker"; import { TidyAssetsWorker } from "tidyAssetsWorker"; import serverConfig from "@karakeep/shared/config"; @@ -28,6 +29,7 @@ async function main() { feed, assetPreprocessing, webhook, + ruleEngine, ] = [ await CrawlerWorker.build(), OpenAiWorker.build(), @@ -37,6 +39,7 @@ async function main() { FeedWorker.build(), AssetPreprocessingWorker.build(), WebhookWorker.build(), + RuleEngineWorker.build(), ]; FeedRefreshingWorker.start(); @@ -50,11 +53,12 @@ async function main() { feed.run(), assetPreprocessing.run(), webhook.run(), + ruleEngine.run(), ]), shutdownPromise, ]); logger.info( - "Shutting down crawler, openai, tidyAssets, video, feed, assetPreprocessing, webhook and search workers ...", + "Shutting down crawler, openai, tidyAssets, video, feed, assetPreprocessing, webhook, ruleEngine and search workers ...", ); FeedRefreshingWorker.stop(); @@ -66,6 +70,7 @@ async function main() { feed.stop(); assetPreprocessing.stop(); webhook.stop(); + ruleEngine.stop(); } main(); diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts index 7b0ae095..c8b2770e 100644 --- a/apps/workers/openaiWorker.ts +++ b/apps/workers/openaiWorker.ts @@ -19,6 +19,7 @@ import logger from "@karakeep/shared/logger"; import { buildImagePrompt, buildTextPrompt } from "@karakeep/shared/prompts"; import { OpenAIQueue, + triggerRuleEngineOnEvent, triggerSearchReindex, triggerWebhook, zOpenAIRequestSchema, @@ -377,19 +378,20 @@ async function connectTags( } // Delete old AI tags - await tx + const detachedTags = await tx .delete(tagsOnBookmarks) .where( and( eq(tagsOnBookmarks.attachedBy, "ai"), eq(tagsOnBookmarks.bookmarkId, bookmarkId), ), - ); + ) + .returning(); const allTagIds = new Set([...matchedTagIds, ...newTagIds]); // Attach new ones - await tx + const attachedTags = await tx .insert(tagsOnBookmarks) .values( [...allTagIds].map((tagId) => ({ @@ -398,7 +400,19 @@ async function connectTags( attachedBy: "ai" as const, })), ) - .onConflictDoNothing(); + .onConflictDoNothing() + .returning(); + + await triggerRuleEngineOnEvent(bookmarkId, [ + ...detachedTags.map((t) => ({ + type: "tagRemoved" as const, + tagId: t.tagId, + })), + ...attachedTags.map((t) => ({ + type: "tagAdded" as const, + tagId: t.tagId, + })), + ]); }); } diff --git a/apps/workers/ruleEngineWorker.ts b/apps/workers/ruleEngineWorker.ts new file mode 100644 index 00000000..427cc383 --- /dev/null +++ b/apps/workers/ruleEngineWorker.ts @@ -0,0 +1,86 @@ +import { eq } from "drizzle-orm"; +import { DequeuedJob, Runner } from "liteque"; +import { buildImpersonatingAuthedContext } from "trpc"; + +import type { ZRuleEngineRequest } from "@karakeep/shared/queues"; +import { db } from "@karakeep/db"; +import { bookmarks } from "@karakeep/db/schema"; +import logger from "@karakeep/shared/logger"; +import { + RuleEngineQueue, + zRuleEngineRequestSchema, +} from "@karakeep/shared/queues"; +import { RuleEngine } from "@karakeep/trpc/lib/ruleEngine"; + +export class RuleEngineWorker { + static build() { + logger.info("Starting rule engine worker ..."); + const worker = new Runner<ZRuleEngineRequest>( + RuleEngineQueue, + { + run: runRuleEngine, + onComplete: (job) => { + const jobId = job.id; + logger.info(`[ruleEngine][${jobId}] Completed successfully`); + return Promise.resolve(); + }, + onError: (job) => { + const jobId = job.id; + logger.error( + `[ruleEngine][${jobId}] rule engine job failed: ${job.error}\n${job.error.stack}`, + ); + return Promise.resolve(); + }, + }, + { + concurrency: 1, + pollIntervalMs: 1000, + timeoutSecs: 10, + validator: zRuleEngineRequestSchema, + }, + ); + + return worker; + } +} + +async function getBookmarkUserId(bookmarkId: string) { + return await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + columns: { + userId: true, + }, + }); +} + +async function runRuleEngine(job: DequeuedJob<ZRuleEngineRequest>) { + const jobId = job.id; + const { bookmarkId, events } = job.data; + + const bookmark = await getBookmarkUserId(bookmarkId); + if (!bookmark) { + throw new Error( + `[ruleEngine][${jobId}] bookmark with id ${bookmarkId} was not found`, + ); + } + const userId = bookmark.userId; + const authedCtx = await buildImpersonatingAuthedContext(userId); + + const ruleEngine = await RuleEngine.forBookmark(authedCtx, bookmarkId); + + const results = ( + await Promise.all(events.map((event) => ruleEngine.onEvent(event))) + ).flat(); + + if (results.length == 0) { + return; + } + + const message = results + .map((result) => `${result.ruleId}, (${result.type}): ${result.message}`) + .join("\n"); + + logger.info( + `[ruleEngine][${jobId}] Rule engine job for bookmark ${bookmarkId} completed with results: ${message}`, + ); +} diff --git a/apps/workers/trpc.ts b/apps/workers/trpc.ts index 8bae287a..c5f880ad 100644 --- a/apps/workers/trpc.ts +++ b/apps/workers/trpc.ts @@ -2,15 +2,15 @@ import { eq } from "drizzle-orm"; import { db } from "@karakeep/db"; import { users } from "@karakeep/db/schema"; -import { createCallerFactory } from "@karakeep/trpc"; +import { AuthedContext, createCallerFactory } from "@karakeep/trpc"; import { appRouter } from "@karakeep/trpc/routers/_app"; /** * This is only safe to use in the context of a worker. */ -export async function buildImpersonatingTRPCClient(userId: string) { - const createCaller = createCallerFactory(appRouter); - +export async function buildImpersonatingAuthedContext( + userId: string, +): Promise<AuthedContext> { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }); @@ -18,7 +18,7 @@ export async function buildImpersonatingTRPCClient(userId: string) { throw new Error("User not found"); } - return createCaller({ + return { user: { id: user.id, name: user.name, @@ -29,5 +29,14 @@ export async function buildImpersonatingTRPCClient(userId: string) { req: { ip: null, }, - }); + }; +} + +/** + * This is only safe to use in the context of a worker. + */ +export async function buildImpersonatingTRPCClient(userId: string) { + const createCaller = createCallerFactory(appRouter); + + return createCaller(await buildImpersonatingAuthedContext(userId)); } diff --git a/apps/workers/webhookWorker.ts b/apps/workers/webhookWorker.ts index fb8227e3..9d3ed2c1 100644 --- a/apps/workers/webhookWorker.ts +++ b/apps/workers/webhookWorker.ts @@ -47,14 +47,17 @@ export class WebhookWorker { } } -async function fetchBookmark(linkId: string) { +async function fetchBookmark(bookmarkId: string) { return await db.query.bookmarks.findFirst({ - where: eq(bookmarks.id, linkId), + where: eq(bookmarks.id, bookmarkId), with: { - link: true, - text: true, - asset: true, + link: { + columns: { + url: true, + }, + }, user: { + columns: {}, with: { webhooks: true, }, |
