From c56cf4e24f6134547fb9c5b58eb20840f5083e9e Mon Sep 17 00:00:00 2001
From: Andrii Mokhovyk <andrii.mokhovyk@gmail.com>
Date: Sun, 18 Jan 2026 16:36:49 +0200
Subject: feat(rules): add "Title Contains" condition to Rule Engine (#1670)
 (#2354)

* feat(rules): add "Title Contains" condition to Rule Engine (#1670)

* feat(rules): hide title conditions for bookmark created trigger

* fix typecheck
---
 .../dashboard/rules/RuleEngineConditionBuilder.tsx | 56 +++++++++++++++++++++-
 1 file changed, 55 insertions(+), 1 deletion(-)

(limited to 'apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx')

diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx
index a859a4cc..28bf690d 100644
--- a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx
+++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx
@@ -19,6 +19,7 @@ import {
   ChevronDown,
   ChevronRight,
   FileType,
+  Heading,
   Link,
   PlusCircle,
   Rss,
@@ -28,7 +29,10 @@ import {
 } from "lucide-react";
 import { useTranslation } from "react-i18next";
 
-import type { RuleEngineCondition } from "@karakeep/shared/types/rules";
+import type {
+  RuleEngineCondition,
+  RuleEngineEvent,
+} from "@karakeep/shared/types/rules";
 
 import { FeedSelector } from "../feeds/FeedSelector";
 import { TagAutocomplete } from "../tags/TagAutocomplete";
@@ -36,6 +40,7 @@ import { TagAutocomplete } from "../tags/TagAutocomplete";
 interface ConditionBuilderProps {
   value: RuleEngineCondition;
   onChange: (condition: RuleEngineCondition) => void;
+  eventType: RuleEngineEvent["type"];
   level?: number;
   onRemove?: () => void;
 }
@@ -43,6 +48,7 @@ interface ConditionBuilderProps {
 export function ConditionBuilder({
   value,
   onChange,
+  eventType,
   level = 0,
   onRemove,
 }: ConditionBuilderProps) {
@@ -57,6 +63,12 @@ export function ConditionBuilder({
       case "urlDoesNotContain":
         onChange({ type: "urlDoesNotContain", str: "" });
         break;
+      case "titleContains":
+        onChange({ type: "titleContains", str: "" });
+        break;
+      case "titleDoesNotContain":
+        onChange({ type: "titleDoesNotContain", str: "" });
+        break;
       case "importedFromFeed":
         onChange({ type: "importedFromFeed", feedId: "" });
         break;
@@ -93,6 +105,9 @@ export function ConditionBuilder({
       case "urlContains":
       case "urlDoesNotContain":
         return <Link className="h-4 w-4" />;
+      case "titleContains":
+      case "titleDoesNotContain":
+        return <Heading className="h-4 w-4" />;
       case "importedFromFeed":
         return <Rss className="h-4 w-4" />;
       case "bookmarkTypeIs":
@@ -134,6 +149,30 @@ export function ConditionBuilder({
           </div>
         );
 
+      case "titleContains":
+        return (
+          <div className="mt-2">
+            <Input
+              value={value.str}
+              onChange={(e) => onChange({ ...value, str: e.target.value })}
+              placeholder="Title contains..."
+              className="w-full"
+            />
+          </div>
+        );
+
+      case "titleDoesNotContain":
+        return (
+          <div className="mt-2">
+            <Input
+              value={value.str}
+              onChange={(e) => onChange({ ...value, str: e.target.value })}
+              placeholder="Title does not contain..."
+              className="w-full"
+            />
+          </div>
+        );
+
       case "importedFromFeed":
         return (
           <div className="mt-2">
@@ -198,6 +237,7 @@ export function ConditionBuilder({
                   newConditions[index] = newCondition;
                   onChange({ ...value, conditions: newConditions });
                 }}
+                eventType={eventType}
                 level={level + 1}
                 onRemove={() => {
                   const newConditions = [...value.conditions];
@@ -233,6 +273,10 @@ export function ConditionBuilder({
     }
   };
 
+  // Title conditions are hidden for "bookmarkAdded" event because
+  // titles are not available at bookmark creation time (they're fetched during crawling)
+  const showTitleConditions = eventType !== "bookmarkAdded";
+
   const ConditionSelector = () => (
     <Select value={value.type} onValueChange={handleTypeChange}>
       <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2">
@@ -254,6 +298,16 @@ export function ConditionBuilder({
         <SelectItem value="urlDoesNotContain">
           {t("settings.rules.conditions_types.url_does_not_contain")}
         </SelectItem>
+        {showTitleConditions && (
+          <SelectItem value="titleContains">
+            {t("settings.rules.conditions_types.title_contains")}
+          </SelectItem>
+        )}
+        {showTitleConditions && (
+          <SelectItem value="titleDoesNotContain">
+            {t("settings.rules.conditions_types.title_does_not_contain")}
+          </SelectItem>
+        )}
         <SelectItem value="importedFromFeed">
           {t("settings.rules.conditions_types.imported_from_feed")}
         </SelectItem>
-- 
cgit v1.2.3-70-g09d2

