Skip to content

Commit c56cf4e

Browse files
authored
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
1 parent 1b98014 commit c56cf4e

6 files changed

Lines changed: 133 additions & 1 deletion

File tree

apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ChevronDown,
2020
ChevronRight,
2121
FileType,
22+
Heading,
2223
Link,
2324
PlusCircle,
2425
Rss,
@@ -28,21 +29,26 @@ import {
2829
} from "lucide-react";
2930
import { useTranslation } from "react-i18next";
3031

31-
import type { RuleEngineCondition } from "@karakeep/shared/types/rules";
32+
import type {
33+
RuleEngineCondition,
34+
RuleEngineEvent,
35+
} from "@karakeep/shared/types/rules";
3236

3337
import { FeedSelector } from "../feeds/FeedSelector";
3438
import { TagAutocomplete } from "../tags/TagAutocomplete";
3539

3640
interface ConditionBuilderProps {
3741
value: RuleEngineCondition;
3842
onChange: (condition: RuleEngineCondition) => void;
43+
eventType: RuleEngineEvent["type"];
3944
level?: number;
4045
onRemove?: () => void;
4146
}
4247

4348
export function ConditionBuilder({
4449
value,
4550
onChange,
51+
eventType,
4652
level = 0,
4753
onRemove,
4854
}: ConditionBuilderProps) {
@@ -57,6 +63,12 @@ export function ConditionBuilder({
5763
case "urlDoesNotContain":
5864
onChange({ type: "urlDoesNotContain", str: "" });
5965
break;
66+
case "titleContains":
67+
onChange({ type: "titleContains", str: "" });
68+
break;
69+
case "titleDoesNotContain":
70+
onChange({ type: "titleDoesNotContain", str: "" });
71+
break;
6072
case "importedFromFeed":
6173
onChange({ type: "importedFromFeed", feedId: "" });
6274
break;
@@ -93,6 +105,9 @@ export function ConditionBuilder({
93105
case "urlContains":
94106
case "urlDoesNotContain":
95107
return <Link className="h-4 w-4" />;
108+
case "titleContains":
109+
case "titleDoesNotContain":
110+
return <Heading className="h-4 w-4" />;
96111
case "importedFromFeed":
97112
return <Rss className="h-4 w-4" />;
98113
case "bookmarkTypeIs":
@@ -134,6 +149,30 @@ export function ConditionBuilder({
134149
</div>
135150
);
136151

152+
case "titleContains":
153+
return (
154+
<div className="mt-2">
155+
<Input
156+
value={value.str}
157+
onChange={(e) => onChange({ ...value, str: e.target.value })}
158+
placeholder="Title contains..."
159+
className="w-full"
160+
/>
161+
</div>
162+
);
163+
164+
case "titleDoesNotContain":
165+
return (
166+
<div className="mt-2">
167+
<Input
168+
value={value.str}
169+
onChange={(e) => onChange({ ...value, str: e.target.value })}
170+
placeholder="Title does not contain..."
171+
className="w-full"
172+
/>
173+
</div>
174+
);
175+
137176
case "importedFromFeed":
138177
return (
139178
<div className="mt-2">
@@ -198,6 +237,7 @@ export function ConditionBuilder({
198237
newConditions[index] = newCondition;
199238
onChange({ ...value, conditions: newConditions });
200239
}}
240+
eventType={eventType}
201241
level={level + 1}
202242
onRemove={() => {
203243
const newConditions = [...value.conditions];
@@ -233,6 +273,10 @@ export function ConditionBuilder({
233273
}
234274
};
235275

276+
// Title conditions are hidden for "bookmarkAdded" event because
277+
// titles are not available at bookmark creation time (they're fetched during crawling)
278+
const showTitleConditions = eventType !== "bookmarkAdded";
279+
236280
const ConditionSelector = () => (
237281
<Select value={value.type} onValueChange={handleTypeChange}>
238282
<SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2">
@@ -254,6 +298,16 @@ export function ConditionBuilder({
254298
<SelectItem value="urlDoesNotContain">
255299
{t("settings.rules.conditions_types.url_does_not_contain")}
256300
</SelectItem>
301+
{showTitleConditions && (
302+
<SelectItem value="titleContains">
303+
{t("settings.rules.conditions_types.title_contains")}
304+
</SelectItem>
305+
)}
306+
{showTitleConditions && (
307+
<SelectItem value="titleDoesNotContain">
308+
{t("settings.rules.conditions_types.title_does_not_contain")}
309+
</SelectItem>
310+
)}
257311
<SelectItem value="importedFromFeed">
258312
{t("settings.rules.conditions_types.imported_from_feed")}
259313
</SelectItem>

apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) {
175175
<ConditionBuilder
176176
value={editedRule.condition}
177177
onChange={handleConditionChange}
178+
eventType={editedRule.event.type}
178179
/>
179180
</div>
180181

apps/web/lib/i18n/locales/en/translation.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@
356356
"always": "Always",
357357
"url_contains": "URL Contains",
358358
"url_does_not_contain": "URL Does Not Contain",
359+
"title_contains": "Title Contains",
360+
"title_does_not_contain": "Title Does Not Contain",
359361
"imported_from_feed": "Imported From Feed",
360362
"bookmark_type_is": "Bookmark Type Is",
361363
"has_tag": "Has Tag",

packages/shared/types/rules.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ const zUrlDoesNotContainCondition = z.object({
5959
str: z.string(),
6060
});
6161

62+
const zTitleContainsCondition = z.object({
63+
type: z.literal("titleContains"),
64+
str: z.string(),
65+
});
66+
67+
const zTitleDoesNotContainCondition = z.object({
68+
type: z.literal("titleDoesNotContain"),
69+
str: z.string(),
70+
});
71+
6272
const zImportedFromFeedCondition = z.object({
6373
type: z.literal("importedFromFeed"),
6474
feedId: z.string(),
@@ -86,6 +96,8 @@ const nonRecursiveCondition = z.discriminatedUnion("type", [
8696
zAlwaysTrueCondition,
8797
zUrlContainsCondition,
8898
zUrlDoesNotContainCondition,
99+
zTitleContainsCondition,
100+
zTitleDoesNotContainCondition,
89101
zImportedFromFeedCondition,
90102
zBookmarkTypeIsCondition,
91103
zHasTagCondition,
@@ -105,6 +117,8 @@ export const zRuleEngineConditionSchema: z.ZodType<RuleEngineCondition> =
105117
zAlwaysTrueCondition,
106118
zUrlContainsCondition,
107119
zUrlDoesNotContainCondition,
120+
zTitleContainsCondition,
121+
zTitleDoesNotContainCondition,
108122
zImportedFromFeedCondition,
109123
zBookmarkTypeIsCondition,
110124
zHasTagCondition,
@@ -244,6 +258,17 @@ const ruleValidaitorFn = (
244258
return false;
245259
}
246260
return true;
261+
case "titleContains":
262+
case "titleDoesNotContain":
263+
if (condition.str.length == 0) {
264+
ctx.addIssue({
265+
code: "custom",
266+
message: "You must specify a title for this condition type",
267+
path: ["condition", "str"],
268+
});
269+
return false;
270+
}
271+
return true;
247272
case "hasTag":
248273
if (condition.tagId.length == 0) {
249274
ctx.addIssue({

packages/trpc/lib/__tests__/ruleEngine.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ describe("RuleEngine", () => {
126126
.values({
127127
userId,
128128
type: BookmarkTypes.LINK,
129+
title: "Example Bookmark Title",
129130
favourited: false,
130131
archived: false,
131132
})
@@ -235,6 +236,38 @@ describe("RuleEngine", () => {
235236
expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
236237
});
237238

239+
it("should return true for titleContains condition", () => {
240+
const condition: RuleEngineCondition = {
241+
type: "titleContains",
242+
str: "Example",
243+
};
244+
expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
245+
});
246+
247+
it("should return false for titleContains condition mismatch", () => {
248+
const condition: RuleEngineCondition = {
249+
type: "titleContains",
250+
str: "nonexistent",
251+
};
252+
expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
253+
});
254+
255+
it("should return false for titleDoesNotContain condition when title contains string", () => {
256+
const condition: RuleEngineCondition = {
257+
type: "titleDoesNotContain",
258+
str: "Example",
259+
};
260+
expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
261+
});
262+
263+
it("should return true for titleDoesNotContain condition when title does not contain string", () => {
264+
const condition: RuleEngineCondition = {
265+
type: "titleDoesNotContain",
266+
str: "nonexistent",
267+
};
268+
expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
269+
});
270+
238271
it("should return true for importedFromFeed condition", () => {
239272
const condition: RuleEngineCondition = {
240273
type: "importedFromFeed",

packages/trpc/lib/ruleEngine.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) {
2222
link: {
2323
columns: {
2424
url: true,
25+
title: true,
2526
},
2627
},
2728
text: true,
@@ -61,6 +62,16 @@ export class RuleEngine {
6162
private rules: RuleEngineRule[],
6263
) {}
6364

65+
private get bookmarkTitle(): string {
66+
return (
67+
this.bookmark.title ??
68+
(this.bookmark.type === BookmarkTypes.LINK
69+
? this.bookmark.link?.title
70+
: "") ??
71+
""
72+
);
73+
}
74+
6475
static async forBookmark(ctx: AuthedContext, bookmarkId: string) {
6576
const [bookmark, rules] = await Promise.all([
6677
fetchBookmark(ctx.db, bookmarkId),
@@ -90,6 +101,12 @@ export class RuleEngine {
90101
!(this.bookmark.link?.url ?? "").includes(condition.str)
91102
);
92103
}
104+
case "titleContains": {
105+
return this.bookmarkTitle.includes(condition.str);
106+
}
107+
case "titleDoesNotContain": {
108+
return !this.bookmarkTitle.includes(condition.str);
109+
}
93110
case "importedFromFeed": {
94111
return this.bookmark.rssFeeds.some(
95112
(f) => f.rssFeedId === condition.feedId,

0 commit comments

Comments
 (0)