aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx56
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx1
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json2
-rw-r--r--packages/shared/types/rules.ts25
-rw-r--r--packages/trpc/lib/__tests__/ruleEngine.test.ts33
-rw-r--r--packages/trpc/lib/ruleEngine.ts17
6 files changed, 133 insertions, 1 deletions
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>
diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx
index d5658a70..e4859b4a 100644
--- a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx
+++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx
@@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) {
<ConditionBuilder
value={editedRule.condition}
onChange={handleConditionChange}
+ eventType={editedRule.event.type}
/>
</div>
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 315564d7..4363f660 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -356,6 +356,8 @@
"always": "Always",
"url_contains": "URL Contains",
"url_does_not_contain": "URL Does Not Contain",
+ "title_contains": "Title Contains",
+ "title_does_not_contain": "Title Does Not Contain",
"imported_from_feed": "Imported From Feed",
"bookmark_type_is": "Bookmark Type Is",
"has_tag": "Has Tag",
diff --git a/packages/shared/types/rules.ts b/packages/shared/types/rules.ts
index 0daec524..fd99c266 100644
--- a/packages/shared/types/rules.ts
+++ b/packages/shared/types/rules.ts
@@ -59,6 +59,16 @@ const zUrlDoesNotContainCondition = z.object({
str: z.string(),
});
+const zTitleContainsCondition = z.object({
+ type: z.literal("titleContains"),
+ str: z.string(),
+});
+
+const zTitleDoesNotContainCondition = z.object({
+ type: z.literal("titleDoesNotContain"),
+ str: z.string(),
+});
+
const zImportedFromFeedCondition = z.object({
type: z.literal("importedFromFeed"),
feedId: z.string(),
@@ -86,6 +96,8 @@ const nonRecursiveCondition = z.discriminatedUnion("type", [
zAlwaysTrueCondition,
zUrlContainsCondition,
zUrlDoesNotContainCondition,
+ zTitleContainsCondition,
+ zTitleDoesNotContainCondition,
zImportedFromFeedCondition,
zBookmarkTypeIsCondition,
zHasTagCondition,
@@ -105,6 +117,8 @@ export const zRuleEngineConditionSchema: z.ZodType<RuleEngineCondition> =
zAlwaysTrueCondition,
zUrlContainsCondition,
zUrlDoesNotContainCondition,
+ zTitleContainsCondition,
+ zTitleDoesNotContainCondition,
zImportedFromFeedCondition,
zBookmarkTypeIsCondition,
zHasTagCondition,
@@ -244,6 +258,17 @@ const ruleValidaitorFn = (
return false;
}
return true;
+ case "titleContains":
+ case "titleDoesNotContain":
+ if (condition.str.length == 0) {
+ ctx.addIssue({
+ code: "custom",
+ message: "You must specify a title for this condition type",
+ path: ["condition", "str"],
+ });
+ return false;
+ }
+ return true;
case "hasTag":
if (condition.tagId.length == 0) {
ctx.addIssue({
diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts
index 600d8aa9..d7f216e5 100644
--- a/packages/trpc/lib/__tests__/ruleEngine.test.ts
+++ b/packages/trpc/lib/__tests__/ruleEngine.test.ts
@@ -126,6 +126,7 @@ describe("RuleEngine", () => {
.values({
userId,
type: BookmarkTypes.LINK,
+ title: "Example Bookmark Title",
favourited: false,
archived: false,
})
@@ -235,6 +236,38 @@ describe("RuleEngine", () => {
expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
});
+ it("should return true for titleContains condition", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleContains",
+ str: "Example",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
+ });
+
+ it("should return false for titleContains condition mismatch", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleContains",
+ str: "nonexistent",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return false for titleDoesNotContain condition when title contains string", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleDoesNotContain",
+ str: "Example",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return true for titleDoesNotContain condition when title does not contain string", () => {
+ const condition: RuleEngineCondition = {
+ type: "titleDoesNotContain",
+ str: "nonexistent",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(true);
+ });
+
it("should return true for importedFromFeed condition", () => {
const condition: RuleEngineCondition = {
type: "importedFromFeed",
diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts
index 6b5f8fdf..233a6acf 100644
--- a/packages/trpc/lib/ruleEngine.ts
+++ b/packages/trpc/lib/ruleEngine.ts
@@ -22,6 +22,7 @@ async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) {
link: {
columns: {
url: true,
+ title: true,
},
},
text: true,
@@ -61,6 +62,16 @@ export class RuleEngine {
private rules: RuleEngineRule[],
) {}
+ private get bookmarkTitle(): string {
+ return (
+ this.bookmark.title ??
+ (this.bookmark.type === BookmarkTypes.LINK
+ ? this.bookmark.link?.title
+ : "") ??
+ ""
+ );
+ }
+
static async forBookmark(ctx: AuthedContext, bookmarkId: string) {
const [bookmark, rules] = await Promise.all([
fetchBookmark(ctx.db, bookmarkId),
@@ -90,6 +101,12 @@ export class RuleEngine {
!(this.bookmark.link?.url ?? "").includes(condition.str)
);
}
+ case "titleContains": {
+ return this.bookmarkTitle.includes(condition.str);
+ }
+ case "titleDoesNotContain": {
+ return !this.bookmarkTitle.includes(condition.str);
+ }
case "importedFromFeed": {
return this.bookmark.rssFeeds.some(
(f) => f.rssFeedId === condition.feedId,