aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/lib/ruleEngine.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/lib/ruleEngine.ts')
-rw-r--r--packages/trpc/lib/ruleEngine.ts231
1 files changed, 231 insertions, 0 deletions
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();
+ }
+}