diff options
| -rw-r--r-- | apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx | 56 | ||||
| -rw-r--r-- | apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx | 1 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 2 | ||||
| -rw-r--r-- | packages/shared/types/rules.ts | 25 | ||||
| -rw-r--r-- | packages/trpc/lib/__tests__/ruleEngine.test.ts | 33 | ||||
| -rw-r--r-- | packages/trpc/lib/ruleEngine.ts | 17 |
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, |
