aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-27 00:02:20 +0100
committerGitHub <noreply@github.com>2025-04-27 00:02:20 +0100
commit136f126296af65f50da598d084d1485c0e40437a (patch)
tree2725c7932ebbcb9b48b5af98eb9b72329a400260
parentca47be7fe7be128f459c37614a04902a873fe289 (diff)
downloadkarakeep-136f126296af65f50da598d084d1485c0e40437a.tar.zst
feat: Implement generic rule engine (#1318)
* Add schema for the new rule engine * Add rule engine backend logic * Implement the worker logic and event firing * Implement the UI changesfor the rule engine * Ensure that when a referenced list or tag are deleted, the corresponding event/action is * Dont show smart lists in rule engine events * Add privacy validations for attached tag and list ids * Move the rules logic into a models
-rw-r--r--apps/web/app/layout.tsx6
-rw-r--r--apps/web/app/settings/layout.tsx6
-rw-r--r--apps/web/app/settings/rules/page.tsx89
-rw-r--r--apps/web/components/dashboard/feeds/FeedSelector.tsx53
-rw-r--r--apps/web/components/dashboard/lists/BookmarkListSelector.tsx11
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx216
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx322
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx107
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx203
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleList.tsx166
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx126
-rw-r--r--apps/web/components/dashboard/tags/TagSelector.tsx5
-rw-r--r--apps/web/components/settings/AddApiKey.tsx6
-rw-r--r--apps/web/components/settings/FeedSettings.tsx3
-rw-r--r--apps/web/components/settings/WebhookSettings.tsx12
-rw-r--r--apps/web/components/shared/sidebar/MobileSidebar.tsx2
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json48
-rw-r--r--apps/workers/index.ts7
-rw-r--r--apps/workers/openaiWorker.ts22
-rw-r--r--apps/workers/ruleEngineWorker.ts86
-rw-r--r--apps/workers/trpc.ts21
-rw-r--r--apps/workers/webhookWorker.ts13
-rw-r--r--packages/db/drizzle/0045_add_rule_engine.sql33
-rw-r--r--packages/db/drizzle/meta/0045_snapshot.json1951
-rw-r--r--packages/db/drizzle/meta/_journal.json9
-rw-r--r--packages/db/schema.ts118
-rw-r--r--packages/shared-react/hooks/rules.ts40
-rw-r--r--packages/shared/queues.ts28
-rw-r--r--packages/shared/types/rules.ts333
-rw-r--r--packages/shared/types/search.ts6
-rw-r--r--packages/trpc/lib/__tests__/ruleEngine.test.ts664
-rw-r--r--packages/trpc/lib/ruleEngine.ts231
-rw-r--r--packages/trpc/models/lists.ts19
-rw-r--r--packages/trpc/models/rules.ts233
-rw-r--r--packages/trpc/package.json2
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/bookmarks.ts27
-rw-r--r--packages/trpc/routers/lists.ts3
-rw-r--r--packages/trpc/routers/rules.test.ts379
-rw-r--r--packages/trpc/routers/rules.ts120
-rw-r--r--packages/trpc/routers/tags.ts2
-rw-r--r--pnpm-lock.yaml97
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&apos;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: