diff options
42 files changed, 5787 insertions, 40 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, }, diff --git a/packages/db/drizzle/0045_add_rule_engine.sql b/packages/db/drizzle/0045_add_rule_engine.sql new file mode 100644 index 00000000..d6d301dd --- /dev/null +++ b/packages/db/drizzle/0045_add_rule_engine.sql @@ -0,0 +1,33 @@ +CREATE TABLE `ruleEngineActions` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `ruleId` text NOT NULL, + `action` text NOT NULL, + `listId` text, + `tagId` text, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`ruleId`) REFERENCES `ruleEngineRules`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`tagId`) REFERENCES `bookmarkTags`(`userId`,`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`listId`) REFERENCES `bookmarkLists`(`userId`,`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `ruleEngineActions_userId_idx` ON `ruleEngineActions` (`userId`);--> statement-breakpoint +CREATE INDEX `ruleEngineActions_ruleId_idx` ON `ruleEngineActions` (`ruleId`);--> statement-breakpoint +CREATE TABLE `ruleEngineRules` ( + `id` text PRIMARY KEY NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `name` text NOT NULL, + `description` text, + `event` text NOT NULL, + `condition` text NOT NULL, + `userId` text NOT NULL, + `listId` text, + `tagId` text, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`tagId`) REFERENCES `bookmarkTags`(`userId`,`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`listId`) REFERENCES `bookmarkLists`(`userId`,`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `ruleEngine_userId_idx` ON `ruleEngineRules` (`userId`);--> statement-breakpoint +CREATE UNIQUE INDEX `bookmarkLists_userId_id_idx` ON `bookmarkLists` (`userId`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `bookmarkTags_userId_id_idx` ON `bookmarkTags` (`userId`,`id`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0045_snapshot.json b/packages/db/drizzle/meta/0045_snapshot.json new file mode 100644 index 00000000..293af6a6 --- /dev/null +++ b/packages/db/drizzle/meta/0045_snapshot.json @@ -0,0 +1,1951 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dce61b2b-d896-425a-a9cd-6a79f596d7b2", + "prevId": "1592d5ec-1cbd-45f5-9061-fbaece6eb91e", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d768270d..8281b126 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1744744684677, "tag": "0044_add_password_salt", "breakpoints": true + }, + { + "idx": 45, + "version": "6", + "when": 1745705657846, + "tag": "0045_add_rule_engine", + "breakpoints": true } ] -} +}
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index dd65370b..bedcf9ad 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3,6 +3,7 @@ import { createId } from "@paralleldrive/cuid2"; import { relations } from "drizzle-orm"; import { AnySQLiteColumn, + foreignKey, index, integer, primaryKey, @@ -283,6 +284,7 @@ export const bookmarkTags = sqliteTable( }, (bt) => [ unique().on(bt.userId, bt.name), + unique("bookmarkTags_userId_id_idx").on(bt.userId, bt.id), index("bookmarkTags_name_idx").on(bt.name), index("bookmarkTags_userId_idx").on(bt.userId), ], @@ -332,7 +334,10 @@ export const bookmarkLists = sqliteTable( { onDelete: "set null" }, ), }, - (bl) => [index("bookmarkLists_userId_idx").on(bl.userId)], + (bl) => [ + index("bookmarkLists_userId_idx").on(bl.userId), + unique("bookmarkLists_userId_id_idx").on(bl.userId, bl.id), + ], ); export const bookmarksInLists = sqliteTable( @@ -444,12 +449,87 @@ export const config = sqliteTable("config", { value: text("value").notNull(), }); +export const ruleEngineRulesTable = sqliteTable( + "ruleEngineRules", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + name: text("name").notNull(), + description: text("description"), + event: text("event").notNull(), + condition: text("condition").notNull(), + + // References + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + listId: text("listId"), + tagId: text("tagId"), + }, + (rl) => [ + index("ruleEngine_userId_idx").on(rl.userId), + + // Ensures correct ownership + foreignKey({ + columns: [rl.userId, rl.tagId], + foreignColumns: [bookmarkTags.userId, bookmarkTags.id], + name: "ruleEngineRules_userId_tagId_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [rl.userId, rl.listId], + foreignColumns: [bookmarkLists.userId, bookmarkLists.id], + name: "ruleEngineRules_userId_listId_fk", + }).onDelete("cascade"), + ], +); + +export const ruleEngineActionsTable = sqliteTable( + "ruleEngineActions", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: text("ruleId") + .notNull() + .references(() => ruleEngineRulesTable.id, { onDelete: "cascade" }), + action: text("action").notNull(), + + // References + listId: text("listId"), + tagId: text("tagId"), + }, + (rl) => [ + index("ruleEngineActions_userId_idx").on(rl.userId), + index("ruleEngineActions_ruleId_idx").on(rl.ruleId), + // Ensures correct ownership + foreignKey({ + columns: [rl.userId, rl.tagId], + foreignColumns: [bookmarkTags.userId, bookmarkTags.id], + name: "ruleEngineActions_userId_tagId_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [rl.userId, rl.listId], + foreignColumns: [bookmarkLists.userId, bookmarkLists.id], + name: "ruleEngineActions_userId_listId_fk", + }).onDelete("cascade"), + ], +); + // Relations export const userRelations = relations(users, ({ many }) => ({ tags: many(bookmarkTags), bookmarks: many(bookmarks), webhooks: many(webhooksTable), + rules: many(ruleEngineRulesTable), })); export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ @@ -472,6 +552,7 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ tagsOnBookmarks: many(tagsOnBookmarks), bookmarksInLists: many(bookmarksInLists), assets: many(assets), + rssFeeds: many(rssFeedImportsTable), })); export const assetRelations = relations(assets, ({ one }) => ({ @@ -548,3 +629,38 @@ export const webhooksRelations = relations(webhooksTable, ({ one }) => ({ references: [users.id], }), })); + +export const ruleEngineRulesRelations = relations( + ruleEngineRulesTable, + ({ one, many }) => ({ + user: one(users, { + fields: [ruleEngineRulesTable.userId], + references: [users.id], + }), + actions: many(ruleEngineActionsTable), + }), +); + +export const ruleEngineActionsTableRelations = relations( + ruleEngineActionsTable, + ({ one }) => ({ + rule: one(ruleEngineRulesTable, { + fields: [ruleEngineActionsTable.ruleId], + references: [ruleEngineRulesTable.id], + }), + }), +); + +export const rssFeedImportsTableRelations = relations( + rssFeedImportsTable, + ({ one }) => ({ + rssFeed: one(rssFeedsTable, { + fields: [rssFeedImportsTable.rssFeedId], + references: [rssFeedsTable.id], + }), + bookmark: one(bookmarks, { + fields: [rssFeedImportsTable.bookmarkId], + references: [bookmarks.id], + }), + }), +); diff --git a/packages/shared-react/hooks/rules.ts b/packages/shared-react/hooks/rules.ts new file mode 100644 index 00000000..16a72f75 --- /dev/null +++ b/packages/shared-react/hooks/rules.ts @@ -0,0 +1,40 @@ +import { api } from "../trpc"; + +export function useCreateRule( + ...opts: Parameters<typeof api.rules.create.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.create.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useUpdateRule( + ...opts: Parameters<typeof api.rules.update.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.update.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useDeleteRule( + ...opts: Parameters<typeof api.rules.delete.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.delete.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} diff --git a/packages/shared/queues.ts b/packages/shared/queues.ts index 624f2bca..571df568 100644 --- a/packages/shared/queues.ts +++ b/packages/shared/queues.ts @@ -3,6 +3,7 @@ import { buildDBClient, migrateDB, SqliteQueue } from "liteque"; import { z } from "zod"; import serverConfig from "./config"; +import { zRuleEngineEventSchema } from "./types/rules"; const QUEUE_DB_PATH = path.join(serverConfig.dataDir, "queue.db"); @@ -193,3 +194,30 @@ export async function triggerWebhook( operation, }); } + +// RuleEgine worker +export const zRuleEngineRequestSchema = z.object({ + bookmarkId: z.string(), + events: z.array(zRuleEngineEventSchema), +}); +export type ZRuleEngineRequest = z.infer<typeof zRuleEngineRequestSchema>; +export const RuleEngineQueue = new SqliteQueue<ZRuleEngineRequest>( + "rule_engine_queue", + queueDB, + { + defaultJobArgs: { + numRetries: 1, + }, + keepFailedJobs: false, + }, +); + +export async function triggerRuleEngineOnEvent( + bookmarkId: string, + events: z.infer<typeof zRuleEngineEventSchema>[], +) { + await RuleEngineQueue.enqueue({ + events, + bookmarkId, + }); +} diff --git a/packages/shared/types/rules.ts b/packages/shared/types/rules.ts new file mode 100644 index 00000000..92300b3c --- /dev/null +++ b/packages/shared/types/rules.ts @@ -0,0 +1,333 @@ +import { RefinementCtx, z } from "zod"; + +// Events +const zBookmarkAddedEvent = z.object({ + type: z.literal("bookmarkAdded"), +}); + +const zTagAddedEvent = z.object({ + type: z.literal("tagAdded"), + tagId: z.string(), +}); + +const zTagRemovedEvent = z.object({ + type: z.literal("tagRemoved"), + tagId: z.string(), +}); + +const zAddedToListEvent = z.object({ + type: z.literal("addedToList"), + listId: z.string(), +}); + +const zRemovedFromListEvent = z.object({ + type: z.literal("removedFromList"), + listId: z.string(), +}); + +const zFavouritedEvent = z.object({ + type: z.literal("favourited"), +}); + +const zArchivedEvent = z.object({ + type: z.literal("archived"), +}); + +export const zRuleEngineEventSchema = z.discriminatedUnion("type", [ + zBookmarkAddedEvent, + zTagAddedEvent, + zTagRemovedEvent, + zAddedToListEvent, + zRemovedFromListEvent, + zFavouritedEvent, + zArchivedEvent, +]); +export type RuleEngineEvent = z.infer<typeof zRuleEngineEventSchema>; + +// Conditions +const zAlwaysTrueCondition = z.object({ + type: z.literal("alwaysTrue"), +}); + +const zUrlContainsCondition = z.object({ + type: z.literal("urlContains"), + str: z.string(), +}); + +const zImportedFromFeedCondition = z.object({ + type: z.literal("importedFromFeed"), + feedId: z.string(), +}); + +const zBookmarkTypeIsCondition = z.object({ + type: z.literal("bookmarkTypeIs"), + bookmarkType: z.enum(["link", "text", "asset"]), +}); + +const zHasTagCondition = z.object({ + type: z.literal("hasTag"), + tagId: z.string(), +}); + +const zIsFavouritedCondition = z.object({ + type: z.literal("isFavourited"), +}); + +const zIsArchivedCondition = z.object({ + type: z.literal("isArchived"), +}); + +const nonRecursiveCondition = z.discriminatedUnion("type", [ + zAlwaysTrueCondition, + zUrlContainsCondition, + zImportedFromFeedCondition, + zBookmarkTypeIsCondition, + zHasTagCondition, + zIsFavouritedCondition, + zIsArchivedCondition, +]); + +type NonRecursiveCondition = z.infer<typeof nonRecursiveCondition>; +export type RuleEngineCondition = + | NonRecursiveCondition + | { type: "and"; conditions: RuleEngineCondition[] } + | { type: "or"; conditions: RuleEngineCondition[] }; + +export const zRuleEngineConditionSchema: z.ZodType<RuleEngineCondition> = + z.lazy(() => + z.discriminatedUnion("type", [ + zAlwaysTrueCondition, + zUrlContainsCondition, + zImportedFromFeedCondition, + zBookmarkTypeIsCondition, + zHasTagCondition, + zIsFavouritedCondition, + zIsArchivedCondition, + z.object({ + type: z.literal("and"), + conditions: z.array(zRuleEngineConditionSchema), + }), + z.object({ + type: z.literal("or"), + conditions: z.array(zRuleEngineConditionSchema), + }), + ]), + ); + +// Actions +const zAddTagAction = z.object({ + type: z.literal("addTag"), + tagId: z.string(), +}); + +const zRemoveTagAction = z.object({ + type: z.literal("removeTag"), + tagId: z.string(), +}); + +const zAddToListAction = z.object({ + type: z.literal("addToList"), + listId: z.string(), +}); + +const zRemoveFromListAction = z.object({ + type: z.literal("removeFromList"), + listId: z.string(), +}); + +const zDownloadFullPageArchiveAction = z.object({ + type: z.literal("downloadFullPageArchive"), +}); + +const zFavouriteBookmarkAction = z.object({ + type: z.literal("favouriteBookmark"), +}); + +const zArchiveBookmarkAction = z.object({ + type: z.literal("archiveBookmark"), +}); + +export const zRuleEngineActionSchema = z.discriminatedUnion("type", [ + zAddTagAction, + zRemoveTagAction, + zAddToListAction, + zRemoveFromListAction, + zDownloadFullPageArchiveAction, + zFavouriteBookmarkAction, + zArchiveBookmarkAction, +]); +export type RuleEngineAction = z.infer<typeof zRuleEngineActionSchema>; + +export const zRuleEngineRuleSchema = z.object({ + id: z.string(), + name: z.string().min(1), + description: z.string().nullable(), + enabled: z.boolean(), + event: zRuleEngineEventSchema, + condition: zRuleEngineConditionSchema, + actions: z.array(zRuleEngineActionSchema), +}); +export type RuleEngineRule = z.infer<typeof zRuleEngineRuleSchema>; + +const ruleValidaitorFn = ( + r: Omit<RuleEngineRule, "id">, + ctx: RefinementCtx, +) => { + const validateEvent = (event: RuleEngineEvent) => { + switch (event.type) { + case "bookmarkAdded": + case "favourited": + case "archived": + return true; + case "tagAdded": + case "tagRemoved": + if (event.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this event type", + path: ["event", "tagId"], + }); + return false; + } + return true; + case "addedToList": + case "removedFromList": + if (event.listId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a list for this event type", + path: ["event", "listId"], + }); + return false; + } + return true; + default: { + const _exhaustiveCheck: never = event; + return false; + } + } + }; + + const validateCondition = ( + condition: RuleEngineCondition, + depth: number, + ): boolean => { + if (depth > 10) { + ctx.addIssue({ + code: "custom", + message: + "Rule conditions are too complex. Maximum allowed depth is 10.", + }); + return false; + } + switch (condition.type) { + case "alwaysTrue": + case "bookmarkTypeIs": + case "isFavourited": + case "isArchived": + return true; + case "urlContains": + if (condition.str.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a URL for this condition type", + path: ["condition", "str"], + }); + return false; + } + return true; + case "hasTag": + if (condition.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this condition type", + path: ["condition", "tagId"], + }); + return false; + } + return true; + case "importedFromFeed": + if (condition.feedId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a feed for this condition type", + path: ["condition", "feedId"], + }); + return false; + } + return true; + case "and": + case "or": + if (condition.conditions.length == 0) { + ctx.addIssue({ + code: "custom", + message: + "You must specify at least one condition for this condition type", + path: ["condition"], + }); + return false; + } + return condition.conditions.every((c) => + validateCondition(c, depth + 1), + ); + default: { + const _exhaustiveCheck: never = condition; + return false; + } + } + }; + const validateAction = (action: RuleEngineAction): boolean => { + switch (action.type) { + case "addTag": + case "removeTag": + if (action.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this action type", + path: ["actions", "tagId"], + }); + return false; + } + return true; + case "addToList": + case "removeFromList": + if (action.listId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a list for this action type", + path: ["actions", "listId"], + }); + return false; + } + return true; + case "downloadFullPageArchive": + case "favouriteBookmark": + case "archiveBookmark": + return true; + default: { + const _exhaustiveCheck: never = action; + return false; + } + } + }; + validateEvent(r.event); + validateCondition(r.condition, 0); + if (r.actions.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify at least one action", + path: ["actions"], + }); + return false; + } + r.actions.every((a) => validateAction(a)); +}; + +export const zNewRuleEngineRuleSchema = zRuleEngineRuleSchema + .omit({ + id: true, + }) + .superRefine(ruleValidaitorFn); + +export const zUpdateRuleEngineRuleSchema = + zRuleEngineRuleSchema.superRefine(ruleValidaitorFn); diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index 4c64c0f5..26d5bd42 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -25,7 +25,7 @@ const zArchivedMatcher = z.object({ archived: z.boolean(), }); -const urlMatcher = z.object({ +const zUrlMatcher = z.object({ type: z.literal("url"), url: z.string(), inverse: z.boolean(), @@ -81,7 +81,7 @@ const zNonRecursiveMatcher = z.union([ zTagNameMatcher, zListNameMatcher, zArchivedMatcher, - urlMatcher, + zUrlMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, @@ -103,7 +103,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => { zTagNameMatcher, zListNameMatcher, zArchivedMatcher, - urlMatcher, + zUrlMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts new file mode 100644 index 00000000..cbb4b978 --- /dev/null +++ b/packages/trpc/lib/__tests__/ruleEngine.test.ts @@ -0,0 +1,664 @@ +import { and, eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getInMemoryDB } from "@karakeep/db/drizzle"; +import { + bookmarkLinks, + bookmarkLists, + bookmarks, + bookmarksInLists, + bookmarkTags, + rssFeedImportsTable, + rssFeedsTable, + ruleEngineActionsTable as ruleActions, + ruleEngineRulesTable as rules, + tagsOnBookmarks, + users, +} from "@karakeep/db/schema"; +import { LinkCrawlerQueue } from "@karakeep/shared/queues"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from "../.."; +import { TestDB } from "../../testUtils"; +import { RuleEngine } from "../ruleEngine"; + +// Mock the queue +vi.mock("@karakeep/shared/queues", () => ({ + LinkCrawlerQueue: { + enqueue: vi.fn(), + }, + triggerRuleEngineOnEvent: vi.fn(), +})); + +describe("RuleEngine", () => { + let db: TestDB; + let ctx: AuthedContext; + let userId: string; + let bookmarkId: string; + let linkBookmarkId: string; + let _textBookmarkId: string; + let tagId1: string; + let tagId2: string; + let feedId1: string; + let listId1: string; + + // Helper to seed a rule + const seedRule = async ( + ruleData: Omit<RuleEngineRule, "id"> & { userId: string }, + ): Promise<string> => { + const [insertedRule] = await db + .insert(rules) + .values({ + userId: ruleData.userId, + name: ruleData.name, + description: ruleData.description, + enabled: ruleData.enabled, + event: JSON.stringify(ruleData.event), + condition: JSON.stringify(ruleData.condition), + }) + .returning({ id: rules.id }); + + await db.insert(ruleActions).values( + ruleData.actions.map((action) => ({ + ruleId: insertedRule.id, + action: JSON.stringify(action), + userId: ruleData.userId, + })), + ); + return insertedRule.id; + }; + + beforeEach(async () => { + vi.resetAllMocks(); + db = getInMemoryDB(/* runMigrations */ true); + + // Seed User + [userId] = ( + await db + .insert(users) + .values({ name: "Test User", email: "test@test.com" }) + .returning({ id: users.id }) + ).map((u) => u.id); + + ctx = { + user: { id: userId, role: "user" }, + db: db, // Cast needed because TestDB might have extra test methods + req: { ip: null }, + }; + + // Seed Tags + [tagId1, tagId2] = ( + await db + .insert(bookmarkTags) + .values([ + { name: "Tag1", userId }, + { name: "Tag2", userId }, + ]) + .returning({ id: bookmarkTags.id }) + ).map((t) => t.id); + + // Seed Feed + [feedId1] = ( + await db + .insert(rssFeedsTable) + .values({ name: "Feed1", userId, url: "https://example.com/feed1" }) + .returning({ id: rssFeedsTable.id }) + ).map((f) => f.id); + + // Seed List + [listId1] = ( + await db + .insert(bookmarkLists) + .values({ name: "List1", userId, type: "manual", icon: "📚" }) + .returning({ id: bookmarkLists.id }) + ).map((l) => l.id); + + // Seed Bookmarks + [linkBookmarkId] = ( + await db + .insert(bookmarks) + .values({ + userId, + type: BookmarkTypes.LINK, + favourited: false, + archived: false, + }) + .returning({ id: bookmarks.id }) + ).map((b) => b.id); + await db.insert(bookmarkLinks).values({ + id: linkBookmarkId, + url: "https://example.com/test", + }); + await db.insert(tagsOnBookmarks).values({ + bookmarkId: linkBookmarkId, + tagId: tagId1, + attachedBy: "human", + }); + await db.insert(rssFeedImportsTable).values({ + bookmarkId: linkBookmarkId, + rssFeedId: feedId1, + entryId: "entry-id", + }); + + [_textBookmarkId] = ( + await db + .insert(bookmarks) + .values({ + userId, + type: BookmarkTypes.TEXT, + favourited: true, + archived: false, + }) + .returning({ id: bookmarks.id }) + ).map((b) => b.id); + + bookmarkId = linkBookmarkId; // Default bookmark for most tests + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("RuleEngine.forBookmark static method", () => { + it("should initialize RuleEngine successfully for an existing bookmark", async () => { + const engine = await RuleEngine.forBookmark(ctx, bookmarkId); + expect(engine).toBeInstanceOf(RuleEngine); + }); + + it("should throw an error if bookmark is not found", async () => { + await expect( + RuleEngine.forBookmark(ctx, "nonexistent-bookmark"), + ).rejects.toThrow("Bookmark nonexistent-bookmark not found"); + }); + + it("should load rules associated with the bookmark's user", async () => { + const ruleId = await seedRule({ + userId, + name: "Test Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + const engine = await RuleEngine.forBookmark(ctx, bookmarkId); + // @ts-expect-error Accessing private property for test verification + expect(engine.rules).toHaveLength(1); + // @ts-expect-error Accessing private property for test verification + expect(engine.rules[0].id).toBe(ruleId); + }); + }); + + describe("doesBookmarkMatchConditions", () => { + let engine: RuleEngine; + + beforeEach(async () => { + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should return true for urlContains condition", () => { + const condition: RuleEngineCondition = { + type: "urlContains", + str: "example.com", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for urlContains condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "urlContains", + str: "nonexistent", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for importedFromFeed condition", () => { + const condition: RuleEngineCondition = { + type: "importedFromFeed", + feedId: feedId1, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for importedFromFeed condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "importedFromFeed", + feedId: "other-feed", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for bookmarkTypeIs condition (link)", () => { + const condition: RuleEngineCondition = { + type: "bookmarkTypeIs", + bookmarkType: BookmarkTypes.LINK, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for bookmarkTypeIs condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "bookmarkTypeIs", + bookmarkType: BookmarkTypes.TEXT, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for hasTag condition", () => { + const condition: RuleEngineCondition = { type: "hasTag", tagId: tagId1 }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for hasTag condition mismatch", () => { + const condition: RuleEngineCondition = { type: "hasTag", tagId: tagId2 }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return false for isFavourited condition (default)", () => { + const condition: RuleEngineCondition = { type: "isFavourited" }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for isFavourited condition when favourited", async () => { + await db + .update(bookmarks) + .set({ favourited: true }) + .where(eq(bookmarks.id, bookmarkId)); + const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const condition: RuleEngineCondition = { type: "isFavourited" }; + expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for isArchived condition (default)", () => { + const condition: RuleEngineCondition = { type: "isArchived" }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for isArchived condition when archived", async () => { + await db + .update(bookmarks) + .set({ archived: true }) + .where(eq(bookmarks.id, bookmarkId)); + const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const condition: RuleEngineCondition = { type: "isArchived" }; + expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle and condition (true)", () => { + const condition: RuleEngineCondition = { + type: "and", + conditions: [ + { type: "urlContains", str: "example" }, + { type: "hasTag", tagId: tagId1 }, + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle and condition (false)", () => { + const condition: RuleEngineCondition = { + type: "and", + conditions: [ + { type: "urlContains", str: "example" }, + { type: "hasTag", tagId: tagId2 }, // This one is false + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should handle or condition (true)", () => { + const condition: RuleEngineCondition = { + type: "or", + conditions: [ + { type: "urlContains", str: "nonexistent" }, // false + { type: "hasTag", tagId: tagId1 }, // true + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle or condition (false)", () => { + const condition: RuleEngineCondition = { + type: "or", + conditions: [ + { type: "urlContains", str: "nonexistent" }, // false + { type: "hasTag", tagId: tagId2 }, // false + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + }); + + describe("evaluateRule", () => { + let ruleId: string; + let engine: RuleEngine; + let testRule: RuleEngineRule; + + beforeEach(async () => { + const tmp = { + id: "", // Will be set after seeding + userId, + name: "Evaluate Rule Test", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + } as Omit<RuleEngineRule, "id"> & { userId: string }; + ruleId = await seedRule(tmp); + testRule = { ...tmp, id: ruleId }; + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should evaluate rule successfully when event and conditions match", async () => { + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(testRule, event); + expect(results).toEqual([ + { type: "success", ruleId: ruleId, message: `Added tag ${tagId2}` }, + ]); + // Verify action was performed + const tags = await db.query.tagsOnBookmarks.findMany({ + where: eq(tagsOnBookmarks.bookmarkId, bookmarkId), + }); + expect(tags.map((t) => t.tagId)).toContain(tagId2); + }); + + it("should return empty array if rule is disabled", async () => { + await db + .update(rules) + .set({ enabled: false }) + .where(eq(rules.id, ruleId)); + const disabledRule = { ...testRule, enabled: false }; + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(disabledRule, event); + expect(results).toEqual([]); + }); + + it("should return empty array if event does not match", async () => { + const event: RuleEngineEvent = { type: "favourited" }; + const results = await engine.evaluateRule(testRule, event); + expect(results).toEqual([]); + }); + + it("should return empty array if condition does not match", async () => { + const nonMatchingRule: RuleEngineRule = { + ...testRule, + condition: { type: "urlContains", str: "nonexistent" }, + }; + await db + .update(rules) + .set({ condition: JSON.stringify(nonMatchingRule.condition) }) + .where(eq(rules.id, ruleId)); + + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(nonMatchingRule, event); + expect(results).toEqual([]); + }); + + it("should return failure result if action fails", async () => { + // Mock addBookmark to throw an error + const listAddBookmarkSpy = vi + .spyOn(RuleEngine.prototype, "executeAction") + .mockImplementation(async (action: RuleEngineAction) => { + if (action.type === "addToList") { + throw new Error("Failed to add to list"); + } + // Call original for other actions if needed, though not strictly necessary here + return Promise.resolve(`Action ${action.type} executed`); + }); + + const ruleWithFailingAction = { + ...testRule, + actions: [{ type: "addToList", listId: "invalid-list" } as const], + }; + await db.delete(ruleActions).where(eq(ruleActions.ruleId, ruleId)); // Clear old actions + await db.insert(ruleActions).values({ + ruleId: ruleId, + action: JSON.stringify(ruleWithFailingAction.actions[0]), + userId, + }); + + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(ruleWithFailingAction, event); + + expect(results).toEqual([ + { + type: "failure", + ruleId: ruleId, + message: "Failed to add to list", + }, + ]); + listAddBookmarkSpy.mockRestore(); + }); + }); + + describe("executeAction", () => { + let engine: RuleEngine; + + beforeEach(async () => { + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should execute addTag action", async () => { + const action: RuleEngineAction = { type: "addTag", tagId: tagId2 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Added tag ${tagId2}`); + const tagLink = await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId2), + ), + }); + expect(tagLink).toBeDefined(); + }); + + it("should execute removeTag action", async () => { + // Ensure tag exists first + expect( + await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId1), + ), + }), + ).toBeDefined(); + + const action: RuleEngineAction = { type: "removeTag", tagId: tagId1 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Removed tag ${tagId1}`); + const tagLink = await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId1), + ), + }); + expect(tagLink).toBeUndefined(); + }); + + it("should execute addToList action", async () => { + const action: RuleEngineAction = { type: "addToList", listId: listId1 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Added to list ${listId1}`); + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeDefined(); + }); + + it("should execute removeFromList action", async () => { + // Add to list first + await db + .insert(bookmarksInLists) + .values({ bookmarkId: bookmarkId, listId: listId1 }); + expect( + await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }), + ).toBeDefined(); + + const action: RuleEngineAction = { + type: "removeFromList", + listId: listId1, + }; + const result = await engine.executeAction(action); + expect(result).toBe(`Removed from list ${listId1}`); + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeUndefined(); + }); + + it("should execute downloadFullPageArchive action", async () => { + const action: RuleEngineAction = { type: "downloadFullPageArchive" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Enqueued full page archive`); + expect(LinkCrawlerQueue.enqueue).toHaveBeenCalledWith({ + bookmarkId: bookmarkId, + archiveFullPage: true, + runInference: false, + }); + }); + + it("should execute favouriteBookmark action", async () => { + let bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(false); + + const action: RuleEngineAction = { type: "favouriteBookmark" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Marked as favourited`); + + bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(true); + }); + + it("should execute archiveBookmark action", async () => { + let bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.archived).toBe(false); + + const action: RuleEngineAction = { type: "archiveBookmark" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Marked as archived`); + + bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.archived).toBe(true); + }); + }); + + describe("onEvent", () => { + let ruleMatchId: string; + let _ruleNoMatchConditionId: string; + let _ruleNoMatchEventId: string; + let _ruleDisabledId: string; + let engine: RuleEngine; + + beforeEach(async () => { + // Rule that should match and execute + ruleMatchId = await seedRule({ + userId, + name: "Match Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + // Rule with non-matching condition + _ruleNoMatchConditionId = await seedRule({ + userId, + name: "No Match Condition Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "nonexistent" }, + actions: [{ type: "favouriteBookmark" }], + }); + + // Rule with non-matching event + _ruleNoMatchEventId = await seedRule({ + userId, + name: "No Match Event Rule", + description: "", + enabled: true, + event: { type: "favourited" }, // Must match rule event type + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "archiveBookmark" }], + }); + + // Disabled rule + _ruleDisabledId = await seedRule({ + userId, + name: "Disabled Rule", + description: "", + enabled: false, // Disabled + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addToList", listId: listId1 }], + }); + + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should process event and return only results for matching, enabled rules", async () => { + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.onEvent(event); + + expect(results).toHaveLength(1); // Only ruleMatchId should produce a result + expect(results[0]).toEqual({ + type: "success", + ruleId: ruleMatchId, + message: `Added tag ${tagId2}`, + }); + + // Verify only the action from the matching rule was executed + const tags = await db.query.tagsOnBookmarks.findMany({ + where: eq(tagsOnBookmarks.bookmarkId, bookmarkId), + }); + expect(tags.map((t) => t.tagId)).toContain(tagId2); // Tag added by ruleMatchId + + const bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(false); // Action from ruleNoMatchConditionId not executed + expect(bm?.archived).toBe(false); // Action from ruleNoMatchEventId not executed + + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeUndefined(); // Action from ruleDisabledId not executed + }); + + it("should return empty array if no rules match the event", async () => { + const event: RuleEngineEvent = { type: "tagAdded", tagId: "some-tag" }; // Event that matches no rules + const results = await engine.onEvent(event); + expect(results).toEqual([]); + }); + }); +}); diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts new file mode 100644 index 00000000..0bef8cdc --- /dev/null +++ b/packages/trpc/lib/ruleEngine.ts @@ -0,0 +1,231 @@ +import deepEql from "deep-equal"; +import { and, eq } from "drizzle-orm"; + +import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema"; +import { LinkCrawlerQueue } from "@karakeep/shared/queues"; +import { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from ".."; +import { List } from "../models/lists"; +import { RuleEngineRuleModel } from "../models/rules"; + +async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) { + return await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + link: { + columns: { + url: true, + }, + }, + text: true, + asset: true, + tagsOnBookmarks: true, + rssFeeds: { + columns: { + rssFeedId: true, + }, + }, + user: { + columns: {}, + with: { + rules: { + with: { + actions: true, + }, + }, + }, + }, + }, + }); +} + +type ReturnedBookmark = NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>; + +export interface RuleEngineEvaluationResult { + type: "success" | "failure"; + ruleId: string; + message: string; +} + +export class RuleEngine { + private constructor( + private ctx: AuthedContext, + private bookmark: Omit<ReturnedBookmark, "user">, + private rules: RuleEngineRule[], + ) {} + + static async forBookmark(ctx: AuthedContext, bookmarkId: string) { + const [bookmark, rules] = await Promise.all([ + fetchBookmark(ctx.db, bookmarkId), + RuleEngineRuleModel.getAll(ctx), + ]); + if (!bookmark) { + throw new Error(`Bookmark ${bookmarkId} not found`); + } + return new RuleEngine( + ctx, + bookmark, + rules.map((r) => r.rule), + ); + } + + doesBookmarkMatchConditions(condition: RuleEngineCondition): boolean { + switch (condition.type) { + case "alwaysTrue": { + return true; + } + case "urlContains": { + return (this.bookmark.link?.url ?? "").includes(condition.str); + } + case "importedFromFeed": { + return this.bookmark.rssFeeds.some( + (f) => f.rssFeedId === condition.feedId, + ); + } + case "bookmarkTypeIs": { + return this.bookmark.type === condition.bookmarkType; + } + case "hasTag": { + return this.bookmark.tagsOnBookmarks.some( + (t) => t.tagId === condition.tagId, + ); + } + case "isFavourited": { + return this.bookmark.favourited; + } + case "isArchived": { + return this.bookmark.archived; + } + case "and": { + return condition.conditions.every((c) => + this.doesBookmarkMatchConditions(c), + ); + } + case "or": { + return condition.conditions.some((c) => + this.doesBookmarkMatchConditions(c), + ); + } + default: { + const _exhaustiveCheck: never = condition; + return false; + } + } + } + + async evaluateRule( + rule: RuleEngineRule, + event: RuleEngineEvent, + ): Promise<RuleEngineEvaluationResult[]> { + if (!rule.enabled) { + return []; + } + if (!deepEql(rule.event, event, { strict: true })) { + return []; + } + if (!this.doesBookmarkMatchConditions(rule.condition)) { + return []; + } + const results = await Promise.allSettled( + rule.actions.map((action) => this.executeAction(action)), + ); + return results.map((result) => { + if (result.status === "fulfilled") { + return { + type: "success", + ruleId: rule.id, + message: result.value, + }; + } else { + return { + type: "failure", + ruleId: rule.id, + message: (result.reason as Error).message, + }; + } + }); + } + + async executeAction(action: RuleEngineAction): Promise<string> { + switch (action.type) { + case "addTag": { + await this.ctx.db + .insert(tagsOnBookmarks) + .values([ + { + attachedBy: "human", + bookmarkId: this.bookmark.id, + tagId: action.tagId, + }, + ]) + .onConflictDoNothing(); + return `Added tag ${action.tagId}`; + } + case "removeTag": { + await this.ctx.db + .delete(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.tagId, action.tagId), + eq(tagsOnBookmarks.bookmarkId, this.bookmark.id), + ), + ); + return `Removed tag ${action.tagId}`; + } + case "addToList": { + const list = await List.fromId(this.ctx, action.listId); + await list.addBookmark(this.bookmark.id); + return `Added to list ${action.listId}`; + } + case "removeFromList": { + const list = await List.fromId(this.ctx, action.listId); + await list.removeBookmark(this.bookmark.id); + return `Removed from list ${action.listId}`; + } + case "downloadFullPageArchive": { + await LinkCrawlerQueue.enqueue({ + bookmarkId: this.bookmark.id, + archiveFullPage: true, + runInference: false, + }); + return `Enqueued full page archive`; + } + case "favouriteBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + favourited: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as favourited`; + } + case "archiveBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + archived: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as archived`; + } + default: { + const _exhaustiveCheck: never = action; + return ""; + } + } + } + + async onEvent(event: RuleEngineEvent): Promise<RuleEngineEvaluationResult[]> { + const results = await Promise.all( + this.rules.map((rule) => this.evaluateRule(rule, event)), + ); + + return results.flat(); + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 8072060f..4da127d2 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema"; +import { triggerRuleEngineOnEvent } from "@karakeep/shared/queues"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList, @@ -117,7 +118,9 @@ export abstract class List implements PrivacyAware { } } - async update(input: z.infer<typeof zEditBookmarkListSchemaWithValidation>) { + async update( + input: z.infer<typeof zEditBookmarkListSchemaWithValidation>, + ): Promise<void> { const result = await this.ctx.db .update(bookmarkLists) .set({ @@ -137,7 +140,7 @@ export abstract class List implements PrivacyAware { if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } - return result[0]; + this.list = result[0]; } abstract get type(): "manual" | "smart"; @@ -248,6 +251,12 @@ export class ManualList extends List { listId: this.list.id, bookmarkId, }); + await triggerRuleEngineOnEvent(bookmarkId, [ + { + type: "addedToList", + listId: this.list.id, + }, + ]); } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { @@ -279,6 +288,12 @@ export class ManualList extends List { message: `Bookmark ${bookmarkId} is already not in list ${this.list.id}`, }); } + await triggerRuleEngineOnEvent(bookmarkId, [ + { + type: "removedFromList", + listId: this.list.id, + }, + ]); } async update(input: z.infer<typeof zEditBookmarkListSchemaWithValidation>) { diff --git a/packages/trpc/models/rules.ts b/packages/trpc/models/rules.ts new file mode 100644 index 00000000..7b17fd8a --- /dev/null +++ b/packages/trpc/models/rules.ts @@ -0,0 +1,233 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { db as DONT_USE_DB } from "@karakeep/db"; +import { + ruleEngineActionsTable, + ruleEngineRulesTable, +} from "@karakeep/db/schema"; +import { + RuleEngineRule, + zNewRuleEngineRuleSchema, + zRuleEngineActionSchema, + zRuleEngineConditionSchema, + zRuleEngineEventSchema, + zUpdateRuleEngineRuleSchema, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from ".."; +import { PrivacyAware } from "./privacy"; + +function dummy_fetchRule(ctx: AuthedContext, id: string) { + return DONT_USE_DB.query.ruleEngineRulesTable.findFirst({ + where: and( + eq(ruleEngineRulesTable.id, id), + eq(ruleEngineRulesTable.userId, ctx.user.id), + ), + with: { + actions: true, // Assuming actions are related; adjust if needed + }, + }); +} + +type FetchedRuleType = NonNullable<Awaited<ReturnType<typeof dummy_fetchRule>>>; + +export class RuleEngineRuleModel implements PrivacyAware { + protected constructor( + protected ctx: AuthedContext, + public rule: RuleEngineRule & { userId: string }, + ) {} + + private static fromData( + ctx: AuthedContext, + ruleData: FetchedRuleType, + ): RuleEngineRuleModel { + return new RuleEngineRuleModel(ctx, { + id: ruleData.id, + userId: ruleData.userId, + name: ruleData.name, + description: ruleData.description, + enabled: ruleData.enabled, + event: zRuleEngineEventSchema.parse(JSON.parse(ruleData.event)), + condition: zRuleEngineConditionSchema.parse( + JSON.parse(ruleData.condition), + ), + actions: ruleData.actions.map((a) => + zRuleEngineActionSchema.parse(JSON.parse(a.action)), + ), + }); + } + + static async fromId( + ctx: AuthedContext, + id: string, + ): Promise<RuleEngineRuleModel> { + const ruleData = await ctx.db.query.ruleEngineRulesTable.findFirst({ + where: and( + eq(ruleEngineRulesTable.id, id), + eq(ruleEngineRulesTable.userId, ctx.user.id), + ), + with: { + actions: true, // Assuming actions are related; adjust if needed + }, + }); + + if (!ruleData) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Rule not found", + }); + } + + return this.fromData(ctx, ruleData); + } + + ensureCanAccess(ctx: AuthedContext): void { + if (this.rule.userId != ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + } + + static async create( + ctx: AuthedContext, + input: z.infer<typeof zNewRuleEngineRuleSchema>, + ): Promise<RuleEngineRuleModel> { + // Similar to lists create, but for rules + const insertedRule = await ctx.db.transaction(async (tx) => { + const [newRule] = await tx + .insert(ruleEngineRulesTable) + .values({ + name: input.name, + description: input.description, + enabled: input.enabled, + event: JSON.stringify(input.event), + condition: JSON.stringify(input.condition), + userId: ctx.user.id, + listId: + input.event.type === "addedToList" || + input.event.type === "removedFromList" + ? input.event.listId + : null, + tagId: + input.event.type === "tagAdded" || input.event.type === "tagRemoved" + ? input.event.tagId + : null, + }) + .returning(); + + if (input.actions.length > 0) { + await tx.insert(ruleEngineActionsTable).values( + input.actions.map((action) => ({ + ruleId: newRule.id, + userId: ctx.user.id, + action: JSON.stringify(action), + listId: + action.type === "addToList" || action.type === "removeFromList" + ? action.listId + : null, + tagId: + action.type === "addTag" || action.type === "removeTag" + ? action.tagId + : null, + })), + ); + } + return newRule; + }); + + // Fetch the full rule after insertion + return await RuleEngineRuleModel.fromId(ctx, insertedRule.id); + } + + async update( + input: z.infer<typeof zUpdateRuleEngineRuleSchema>, + ): Promise<void> { + if (this.rule.id !== input.id) { + throw new TRPCError({ code: "BAD_REQUEST", message: "ID mismatch" }); + } + + await this.ctx.db.transaction(async (tx) => { + const result = await tx + .update(ruleEngineRulesTable) + .set({ + name: input.name, + description: input.description, + enabled: input.enabled, + event: JSON.stringify(input.event), + condition: JSON.stringify(input.condition), + listId: + input.event.type === "addedToList" || + input.event.type === "removedFromList" + ? input.event.listId + : null, + tagId: + input.event.type === "tagAdded" || input.event.type === "tagRemoved" + ? input.event.tagId + : null, + }) + .where( + and( + eq(ruleEngineRulesTable.id, input.id), + eq(ruleEngineRulesTable.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" }); + } + + if (input.actions.length > 0) { + await tx + .delete(ruleEngineActionsTable) + .where(eq(ruleEngineActionsTable.ruleId, input.id)); + await tx.insert(ruleEngineActionsTable).values( + input.actions.map((action) => ({ + ruleId: input.id, + userId: this.ctx.user.id, + action: JSON.stringify(action), + listId: + action.type === "addToList" || action.type === "removeFromList" + ? action.listId + : null, + tagId: + action.type === "addTag" || action.type === "removeTag" + ? action.tagId + : null, + })), + ); + } + }); + + this.rule = await RuleEngineRuleModel.fromId(this.ctx, this.rule.id).then( + (r) => r.rule, + ); + } + + async delete(): Promise<void> { + const result = await this.ctx.db + .delete(ruleEngineRulesTable) + .where( + and( + eq(ruleEngineRulesTable.id, this.rule.id), + eq(ruleEngineRulesTable.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" }); + } + } + + static async getAll(ctx: AuthedContext): Promise<RuleEngineRuleModel[]> { + const rulesData = await ctx.db.query.ruleEngineRulesTable.findMany({ + where: eq(ruleEngineRulesTable.userId, ctx.user.id), + with: { actions: true }, + }); + + return rulesData.map((r) => this.fromData(ctx, r)); + } +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 94fdee1b..5b5bad86 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -17,6 +17,7 @@ "@karakeep/shared": "workspace:*", "@trpc/server": "11.0.0", "bcryptjs": "^2.4.3", + "deep-equal": "^2.2.3", "drizzle-orm": "^0.38.3", "superjson": "^2.2.1", "tiny-invariant": "^1.3.3", @@ -27,6 +28,7 @@ "@karakeep/prettier-config": "workspace:^0.1.0", "@karakeep/tsconfig": "workspace:^0.1.0", "@types/bcryptjs": "^2.4.6", + "@types/deep-equal": "^1.0.4", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.6.1" }, diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 7af19884..394e95e7 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -7,6 +7,7 @@ import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; +import { rulesAppRouter } from "./rules"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; import { webhooksAppRouter } from "./webhooks"; @@ -23,6 +24,7 @@ export const appRouter = router({ highlights: highlightsAppRouter, webhooks: webhooksAppRouter, assets: assetsAppRouter, + rules: rulesAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 9a1b6b0b..b9a21400 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -45,6 +45,7 @@ import { AssetPreprocessingQueue, LinkCrawlerQueue, OpenAIQueue, + triggerRuleEngineOnEvent, triggerSearchDeletion, triggerSearchReindex, triggerWebhook, @@ -430,6 +431,11 @@ export const bookmarksAppRouter = router({ break; } } + await triggerRuleEngineOnEvent(bookmark.id, [ + { + type: "bookmarkAdded", + }, + ]); await triggerSearchReindex(bookmark.id); await triggerWebhook(bookmark.id, "created"); return bookmark; @@ -573,6 +579,17 @@ export const bookmarksAppRouter = router({ /* includeContent: */ false, ); + if (input.favourited === true || input.archived === true) { + await triggerRuleEngineOnEvent( + input.bookmarkId, + [ + ...(input.favourited === true ? ["favourited" as const] : []), + ...(input.archived === true ? ["archived" as const] : []), + ].map((t) => ({ + type: t, + })), + ); + } // Trigger re-indexing and webhooks await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); @@ -1141,6 +1158,16 @@ export const bookmarksAppRouter = router({ ), ); + await triggerRuleEngineOnEvent(input.bookmarkId, [ + ...idsToRemove.map((t) => ({ + type: "tagRemoved" as const, + tagId: t, + })), + ...allIds.map((t) => ({ + type: "tagAdded" as const, + tagId: t, + })), + ]); await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); return { diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 12960316..65cffd2d 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -38,7 +38,8 @@ export const listsAppRouter = router({ .output(zBookmarkListSchema) .use(ensureListOwnership) .mutation(async ({ input, ctx }) => { - return await ctx.list.update(input); + await ctx.list.update(input); + return ctx.list.list; }), merge: authedProcedure .input(zMergeListSchema) diff --git a/packages/trpc/routers/rules.test.ts b/packages/trpc/routers/rules.test.ts new file mode 100644 index 00000000..6bbbcd84 --- /dev/null +++ b/packages/trpc/routers/rules.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { RuleEngineRule } from "@karakeep/shared/types/rules"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +describe("Rules Routes", () => { + let tagId1: string; + let tagId2: string; + let otherUserTagId: string; + + let listId: string; + let otherUserListId: string; + + beforeEach<CustomTestContext>(async (ctx) => { + await defaultBeforeEach(true)(ctx); + + tagId1 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 1", + }) + ).id; + + tagId2 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 2", + }) + ).id; + + otherUserTagId = ( + await ctx.apiCallers[1].tags.create({ + name: "Tag 1", + }) + ).id; + + listId = ( + await ctx.apiCallers[0].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + + otherUserListId = ( + await ctx.apiCallers[1].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + }); + + test<CustomTestContext>("create rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const validRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [ + { type: "addTag", tagId: tagId1 }, + { type: "addToList", listId: listId }, + ], + }; + + const createdRule = await api.create(validRuleInput); + expect(createdRule).toMatchObject({ + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: validRuleInput.event, + condition: validRuleInput.condition, + actions: validRuleInput.actions, + }); + }); + + test<CustomTestContext>("create rule fails with invalid data (no actions)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Missing actions", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [], // Empty actions array - should fail validation + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify at least one action/, + ); + }); + + test<CustomTestContext>("create rule fails with invalid event (empty tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Invalid event", + enabled: true, + event: { type: "tagAdded", tagId: "" }, // Empty tagId - should fail + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this event type/, + ); + }); + + test<CustomTestContext>("create rule fails with invalid condition (empty tagId in hasTag)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Invalid condition", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: "" }, // Empty tagId - should fail + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this condition type/, + ); + }); + + test<CustomTestContext>("update rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const validUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: tagId2 }], + }; + + const updatedRule = await api.update(validUpdateInput); + expect(updatedRule).toMatchObject({ + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: validUpdateInput.event, + condition: validUpdateInput.condition, + actions: validUpdateInput.actions, + }); + }); + + test<CustomTestContext>("update rule fails with invalid data (empty action tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const invalidUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: "" }], // Empty tagId - should fail + }; + + await expect(() => api.update(invalidUpdateInput)).rejects.toThrow( + /You must specify a tag for this action type/, + ); + }); + + test<CustomTestContext>("delete rule", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + const createdRule = await api.create({ + name: "Rule to Delete", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.delete({ id: createdRule.id }); + + // Attempt to fetch the rule should fail + await expect(() => + api.update({ ...createdRule, name: "Updated" }), + ).rejects.toThrow(/Rule not found/); + }); + + test<CustomTestContext>("list rules", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + await api.create({ + name: "Rule 1", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.create({ + name: "Rule 2", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + const rulesList = await api.list(); + expect(rulesList.rules.length).toBeGreaterThanOrEqual(2); + expect(rulesList.rules.some((rule) => rule.name === "Rule 1")).toBeTruthy(); + expect(rulesList.rules.some((rule) => rule.name === "Rule 2")).toBeTruthy(); + }); + + describe("privacy checks", () => { + test<CustomTestContext>("cannot access or manipulate another user's rule", async ({ + apiCallers, + }) => { + const apiUserA = apiCallers[0].rules; // First user + const apiUserB = apiCallers[1].rules; // Second user + + // User A creates a rule + const createdRule = await apiUserA.create({ + name: "User A's Rule", + description: "A rule for User A", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + // User B tries to update User A's rule + const updateInput: RuleEngineRule = { + id: createdRule.id, + name: "Trying to Update", + description: "Unauthorized update", + enabled: true, + event: createdRule.event, + condition: createdRule.condition, + actions: createdRule.actions, + }; + + await expect(() => apiUserB.update(updateInput)).rejects.toThrow( + /Rule not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with event on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Event with other user's tag", + enabled: true, + event: { type: "tagAdded", tagId: otherUserTagId }, // Other user's tag + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, // Expect an error indicating lack of ownership + ); + }); + + test<CustomTestContext>("cannot create rule with condition on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Condition with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: otherUserTagId }, // Other user's tag + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with action on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Action with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: otherUserTagId }], // Other user's tag + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with event on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Event with other user's list", + enabled: true, + event: { type: "addedToList", listId: otherUserListId }, // Other user's list + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with action on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Action with other user's list", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addToList", listId: otherUserListId }], // Other user's list + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + }); +}); diff --git a/packages/trpc/routers/rules.ts b/packages/trpc/routers/rules.ts new file mode 100644 index 00000000..5def8003 --- /dev/null +++ b/packages/trpc/routers/rules.ts @@ -0,0 +1,120 @@ +import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { bookmarkTags } from "@karakeep/db/schema"; +import { + RuleEngineRule, + zNewRuleEngineRuleSchema, + zRuleEngineRuleSchema, + zUpdateRuleEngineRuleSchema, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext, authedProcedure, router } from "../index"; +import { List } from "../models/lists"; +import { RuleEngineRuleModel } from "../models/rules"; + +const ensureRuleOwnership = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: { id: string }; +}>().create(async (opts) => { + const rule = await RuleEngineRuleModel.fromId(opts.ctx, opts.input.id); + return opts.next({ + ctx: { + ...opts.ctx, + rule, + }, + }); +}); + +const ensureTagListOwnership = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: Omit<RuleEngineRule, "id">; +}>().create(async (opts) => { + const tagIds = [ + ...(opts.input.event.type === "tagAdded" || + opts.input.event.type === "tagRemoved" + ? [opts.input.event.tagId] + : []), + ...(opts.input.condition.type === "hasTag" + ? [opts.input.condition.tagId] + : []), + ...opts.input.actions.flatMap((a) => + a.type == "addTag" || a.type == "removeTag" ? [a.tagId] : [], + ), + ]; + + const validateTags = async () => { + if (tagIds.length == 0) { + return; + } + const userTags = await opts.ctx.db.query.bookmarkTags.findMany({ + where: and( + eq(bookmarkTags.userId, opts.ctx.user.id), + inArray(bookmarkTags.id, tagIds), + ), + columns: { + id: true, + }, + }); + if (tagIds.some((t) => userTags.find((u) => u.id == t) == null)) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Tag not found", + }); + } + }; + + const listIds = [ + ...(opts.input.event.type === "addedToList" || + opts.input.event.type === "removedFromList" + ? [opts.input.event.listId] + : []), + ...opts.input.actions.flatMap((a) => + a.type == "addToList" || a.type == "removeFromList" ? [a.listId] : [], + ), + ]; + + const [_tags, _lists] = await Promise.all([ + validateTags(), + Promise.all(listIds.map((l) => List.fromId(opts.ctx, l))), + ]); + return opts.next(); +}); + +export const rulesAppRouter = router({ + create: authedProcedure + .input(zNewRuleEngineRuleSchema) + .output(zRuleEngineRuleSchema) + .use(ensureTagListOwnership) + .mutation(async ({ input, ctx }) => { + const newRule = await RuleEngineRuleModel.create(ctx, input); + return newRule.rule; + }), + update: authedProcedure + .input(zUpdateRuleEngineRuleSchema) + .output(zRuleEngineRuleSchema) + .use(ensureRuleOwnership) + .use(ensureTagListOwnership) + .mutation(async ({ ctx, input }) => { + await ctx.rule.update(input); + return ctx.rule.rule; + }), + delete: authedProcedure + .input(z.object({ id: z.string() })) + .use(ensureRuleOwnership) + .mutation(async ({ ctx }) => { + await ctx.rule.delete(); + }), + list: authedProcedure + .output( + z.object({ + rules: z.array(zRuleEngineRuleSchema), + }), + ) + .query(async ({ ctx }) => { + return { + rules: (await RuleEngineRuleModel.getAll(ctx)).map((r) => r.rule), + }; + }), +}); diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts index cdf47f4f..7f75c16e 100644 --- a/packages/trpc/routers/tags.ts +++ b/packages/trpc/routers/tags.ts @@ -18,7 +18,7 @@ function conditionFromInput(input: { tagId: string }, userId: string) { return and(eq(bookmarkTags.id, input.tagId), eq(bookmarkTags.userId, userId)); } -const ensureTagOwnership = experimental_trpcMiddleware<{ +export const ensureTagOwnership = experimental_trpcMiddleware<{ ctx: Context; input: { tagId: string }; }>().create(async (opts) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5b062f..42850157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1178,6 +1178,9 @@ importers: bcryptjs: specifier: ^2.4.3 version: 2.4.3 + deep-equal: + specifier: ^2.2.3 + version: 2.2.3 drizzle-orm: specifier: ^0.38.3 version: 0.38.3(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) @@ -1203,6 +1206,9 @@ importers: '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 + '@types/deep-equal': + specifier: ^1.0.4 + version: 1.0.4 vite-tsconfig-paths: specifier: ^4.3.1 version: 4.3.1(typescript@5.7.3) @@ -5616,6 +5622,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-equal@1.0.4': + resolution: {integrity: sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==} + '@types/emoji-mart@3.0.14': resolution: {integrity: sha512-/vMkVnet466bK37ugf2jry9ldCZklFPXYMB2m+qNo3vkP2I7L0cvtNFPKAjfcHgPg9Z8pbYqVqZn7AgsC0qf+g==} @@ -7578,6 +7587,10 @@ packages: resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -8100,6 +8113,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-iterator-helpers@1.0.17: resolution: {integrity: sha512-lh7BsUqelv4KUbR5a/ZTaGGIMLCjPGPqJ6q+Oq24YP0RdyptX1uzm4vvaqzk7Zx3bpl/76YLTTDj9L7uYQ92oQ==} engines: {node: '>= 0.4'} @@ -9605,6 +9621,10 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -11486,6 +11506,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -13887,6 +13911,10 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -15921,7 +15949,6 @@ snapshots: '@babel/parser@7.27.0': dependencies: '@babel/types': 7.27.0 - dev: false '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': dependencies: @@ -17247,7 +17274,6 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - dev: false '@colors/colors@1.5.0': dev: false @@ -21562,6 +21588,9 @@ snapshots: dependencies: '@types/ms': 0.7.34 + '@types/deep-equal@1.0.4': + dev: true + '@types/emoji-mart@3.0.14': dependencies: '@types/react': 18.2.58 @@ -22171,7 +22200,7 @@ snapshots: '@vue/compiler-core@3.5.13': dependencies: - '@babel/parser': 7.26.2 + '@babel/parser': 7.27.0 '@vue/shared': 3.5.13 entities: 4.5.0 estree-walker: 2.0.2 @@ -23291,10 +23320,10 @@ snapshots: call-bind@1.0.7: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 set-function-length: 1.2.1 call-bound@1.0.4: @@ -24244,6 +24273,28 @@ snapshots: type-detect: 4.0.8 dev: true + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.1.1 + is-array-buffer: 3.0.4 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + side-channel: 1.1.0 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.14 + dev: false + deep-extend@0.6.0: dev: false @@ -24745,6 +24796,19 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.1.1 + is-map: 2.0.2 + is-set: 2.0.2 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + dev: false + es-iterator-helpers@1.0.17: dependencies: asynciterator.prototype: 1.0.0 @@ -26992,6 +27056,13 @@ snapshots: hasown: 2.0.1 side-channel: 1.0.6 + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + dev: false + interpret@1.4.0: {} invariant@2.2.4: @@ -27044,7 +27115,7 @@ snapshots: is-array-buffer@3.0.4: dependencies: call-bind: 1.0.7 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-arrayish@0.2.1: {} @@ -29940,6 +30011,12 @@ snapshots: object-inspect@1.13.4: dev: false + object-is@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + dev: false + object-keys@1.1.1: {} object.assign@4.1.5: @@ -33241,6 +33318,12 @@ snapshots: std-env@3.7.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + dev: false + stream-buffers@2.2.0: dev: false @@ -34805,7 +34888,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-tostringtag: 1.0.2 which@1.3.1: |
