aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/lib
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/lib')
-rw-r--r--packages/trpc/lib/__tests__/ruleEngine.test.ts70
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts55
-rw-r--r--packages/trpc/lib/attachments.ts10
-rw-r--r--packages/trpc/lib/ruleEngine.ts31
-rw-r--r--packages/trpc/lib/search.ts91
-rw-r--r--packages/trpc/lib/tracing.ts63
6 files changed, 285 insertions, 35 deletions
diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts
index ede22ec6..b737e3a5 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,
})
@@ -171,10 +172,9 @@ describe("RuleEngine", () => {
expect(engine).toBeInstanceOf(RuleEngine);
});
- it("should throw an error if bookmark is not found", async () => {
- await expect(
- RuleEngine.forBookmark(ctx, "nonexistent-bookmark"),
- ).rejects.toThrow("Bookmark nonexistent-bookmark not found");
+ it("should return null if bookmark is not found", async () => {
+ const engine = await RuleEngine.forBookmark(ctx, "nonexistent-bookmark");
+ expect(engine).toBeNull();
});
it("should load rules associated with the bookmark's user", async () => {
@@ -188,7 +188,7 @@ describe("RuleEngine", () => {
actions: [{ type: "addTag", tagId: tagId2 }],
});
- const engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
// @ts-expect-error Accessing private property for test verification
expect(engine.rules).toHaveLength(1);
// @ts-expect-error Accessing private property for test verification
@@ -200,7 +200,7 @@ describe("RuleEngine", () => {
let engine: RuleEngine;
beforeEach(async () => {
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should return true for urlContains condition", () => {
@@ -219,6 +219,54 @@ describe("RuleEngine", () => {
expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
});
+ it("should return false for urlDoesNotContain condition when URL contains string", () => {
+ const condition: RuleEngineCondition = {
+ type: "urlDoesNotContain",
+ str: "example.com",
+ };
+ expect(engine.doesBookmarkMatchConditions(condition)).toBe(false);
+ });
+
+ it("should return true for urlDoesNotContain condition when URL does not contain string", () => {
+ const condition: RuleEngineCondition = {
+ type: "urlDoesNotContain",
+ str: "nonexistent",
+ };
+ 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",
@@ -271,7 +319,7 @@ describe("RuleEngine", () => {
.update(bookmarks)
.set({ favourited: true })
.where(eq(bookmarks.id, bookmarkId));
- const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
const condition: RuleEngineCondition = { type: "isFavourited" };
expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true);
});
@@ -286,7 +334,7 @@ describe("RuleEngine", () => {
.update(bookmarks)
.set({ archived: true })
.where(eq(bookmarks.id, bookmarkId));
- const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ const updatedEngine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
const condition: RuleEngineCondition = { type: "isArchived" };
expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true);
});
@@ -354,7 +402,7 @@ describe("RuleEngine", () => {
} as Omit<RuleEngineRule, "id"> & { userId: string };
ruleId = await seedRule(tmp);
testRule = { ...tmp, id: ruleId };
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should evaluate rule successfully when event and conditions match", async () => {
@@ -443,7 +491,7 @@ describe("RuleEngine", () => {
let engine: RuleEngine;
beforeEach(async () => {
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should execute addTag action", async () => {
@@ -625,7 +673,7 @@ describe("RuleEngine", () => {
actions: [{ type: "addToList", listId: listId1 }],
});
- engine = await RuleEngine.forBookmark(ctx, bookmarkId);
+ engine = (await RuleEngine.forBookmark(ctx, bookmarkId))!;
});
it("should process event and return only results for matching, enabled rules", async () => {
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts
index ee8bfb60..d39e27a6 100644
--- a/packages/trpc/lib/__tests__/search.test.ts
+++ b/packages/trpc/lib/__tests__/search.test.ts
@@ -240,6 +240,61 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]);
});
+ it("should handle listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: false,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b1", "b2", "b6"]);
+ });
+
+ it("should handle inverse listName matcher when multiple lists share the same name", async () => {
+ await mockCtx.db.insert(bookmarkLists).values({
+ id: "l5",
+ userId: testUserId,
+ name: "list1",
+ icon: "🚀",
+ type: "manual",
+ });
+ await mockCtx.db.insert(bookmarksInLists).values({
+ bookmarkId: "b2",
+ listId: "l5",
+ });
+
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "list1",
+ inverse: true,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result.sort()).toEqual(["b3", "b4", "b5"]);
+ });
+
+ it("should return empty when inverse listName references a missing list", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "does-not-exist",
+ inverse: true,
+ };
+
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual([]);
+ });
+
it("should handle archived matcher", async () => {
const matcher: Matcher = { type: "archived", archived: true };
const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts
index 25d9be94..f3170c22 100644
--- a/packages/trpc/lib/attachments.ts
+++ b/packages/trpc/lib/attachments.ts
@@ -9,6 +9,7 @@ import {
export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = {
[AssetTypes.LINK_SCREENSHOT]: "screenshot",
+ [AssetTypes.LINK_PDF]: "pdf",
[AssetTypes.ASSET_SCREENSHOT]: "assetScreenshot",
[AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive",
[AssetTypes.LINK_PRECRAWLED_ARCHIVE]: "precrawledArchive",
@@ -17,6 +18,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
[AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent",
[AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset",
[AssetTypes.USER_UPLOADED]: "userUploaded",
+ [AssetTypes.AVATAR]: "avatar",
[AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets
[AssetTypes.UNKNOWN]: "bannerImage",
};
@@ -28,6 +30,7 @@ export function mapSchemaAssetTypeToDB(
): AssetTypes {
const map: Record<ZAssetType, AssetTypes> = {
screenshot: AssetTypes.LINK_SCREENSHOT,
+ pdf: AssetTypes.LINK_PDF,
assetScreenshot: AssetTypes.ASSET_SCREENSHOT,
fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
precrawledArchive: AssetTypes.LINK_PRECRAWLED_ARCHIVE,
@@ -36,6 +39,7 @@ export function mapSchemaAssetTypeToDB(
bookmarkAsset: AssetTypes.BOOKMARK_ASSET,
linkHtmlContent: AssetTypes.LINK_HTML_CONTENT,
userUploaded: AssetTypes.USER_UPLOADED,
+ avatar: AssetTypes.AVATAR,
unknown: AssetTypes.UNKNOWN,
};
return map[assetType];
@@ -44,6 +48,7 @@ export function mapSchemaAssetTypeToDB(
export function humanFriendlyNameForAssertType(type: ZAssetType) {
const map: Record<ZAssetType, string> = {
screenshot: "Screenshot",
+ pdf: "PDF",
assetScreenshot: "Asset Screenshot",
fullPageArchive: "Full Page Archive",
precrawledArchive: "Precrawled Archive",
@@ -52,6 +57,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
bookmarkAsset: "Bookmark Asset",
linkHtmlContent: "HTML Content",
userUploaded: "User Uploaded File",
+ avatar: "Avatar",
unknown: "Unknown",
};
return map[type];
@@ -60,6 +66,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
export function isAllowedToAttachAsset(type: ZAssetType) {
const map: Record<ZAssetType, boolean> = {
screenshot: true,
+ pdf: true,
assetScreenshot: true,
fullPageArchive: false,
precrawledArchive: true,
@@ -68,6 +75,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
@@ -76,6 +84,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
export function isAllowedToDetachAsset(type: ZAssetType) {
const map: Record<ZAssetType, boolean> = {
screenshot: true,
+ pdf: true,
assetScreenshot: true,
fullPageArchive: true,
precrawledArchive: true,
@@ -84,6 +93,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts
index c191619b..acfd747e 100644
--- a/packages/trpc/lib/ruleEngine.ts
+++ b/packages/trpc/lib/ruleEngine.ts
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema";
import { LinkCrawlerQueue } from "@karakeep/shared-server";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import {
RuleEngineAction,
RuleEngineCondition,
@@ -21,6 +22,7 @@ async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) {
link: {
columns: {
url: true,
+ title: true,
},
},
text: true,
@@ -60,13 +62,26 @@ export class RuleEngine {
private rules: RuleEngineRule[],
) {}
- static async forBookmark(ctx: AuthedContext, bookmarkId: string) {
+ private get bookmarkTitle(): string {
+ return (
+ this.bookmark.title ??
+ (this.bookmark.type === BookmarkTypes.LINK
+ ? this.bookmark.link?.title
+ : "") ??
+ ""
+ );
+ }
+
+ static async forBookmark(
+ ctx: AuthedContext,
+ bookmarkId: string,
+ ): Promise<RuleEngine | null> {
const [bookmark, rules] = await Promise.all([
fetchBookmark(ctx.db, bookmarkId),
RuleEngineRuleModel.getAll(ctx),
]);
if (!bookmark) {
- throw new Error(`Bookmark ${bookmarkId} not found`);
+ return null;
}
return new RuleEngine(
ctx,
@@ -83,6 +98,18 @@ export class RuleEngine {
case "urlContains": {
return (this.bookmark.link?.url ?? "").includes(condition.str);
}
+ case "urlDoesNotContain": {
+ return (
+ this.bookmark.type == BookmarkTypes.LINK &&
+ !(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,
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
index 88f10f22..d0f529f5 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -4,6 +4,7 @@ import {
exists,
gt,
gte,
+ inArray,
isNotNull,
isNull,
like,
@@ -11,6 +12,7 @@ import {
lte,
ne,
notExists,
+ notInArray,
notLike,
or,
} from "drizzle-orm";
@@ -89,10 +91,13 @@ function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] {
}
async function getIds(
- db: AuthedContext["db"],
- userId: string,
+ ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<BookmarkQueryReturnType[]> {
+ const { db } = ctx;
+ const userId = ctx.user.id;
+
switch (matcher.type) {
case "tagName": {
const comp = matcher.inverse ? notExists : exists;
@@ -139,29 +144,54 @@ async function getIds(
);
}
case "listName": {
- const comp = matcher.inverse ? notExists : exists;
+ // First, look up the list by name
+ const lists = await db.query.bookmarkLists.findMany({
+ where: and(
+ eq(bookmarkLists.userId, userId),
+ eq(bookmarkLists.name, matcher.listName),
+ ),
+ });
+
+ if (lists.length === 0) {
+ // No matching lists
+ return [];
+ }
+
+ // Use List model to resolve list membership (manual and smart)
+ // Import dynamically to avoid circular dependency
+ const { List } = await import("../models/lists");
+ const listBookmarkIds = [
+ ...new Set(
+ (
+ await Promise.all(
+ lists.map(async (list) => {
+ const listModel = await List.fromId(ctx, list.id);
+ return await listModel.getBookmarkIds(visitedListIds);
+ }),
+ )
+ ).flat(),
+ ),
+ ];
+
+ if (listBookmarkIds.length === 0) {
+ if (matcher.inverse) {
+ return db
+ .selectDistinct({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, userId));
+ }
+ return [];
+ }
+
return db
.selectDistinct({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
eq(bookmarks.userId, userId),
- comp(
- db
- .select()
- .from(bookmarksInLists)
- .innerJoin(
- bookmarkLists,
- eq(bookmarksInLists.listId, bookmarkLists.id),
- )
- .where(
- and(
- eq(bookmarksInLists.bookmarkId, bookmarks.id),
- eq(bookmarkLists.userId, userId),
- eq(bookmarkLists.name, matcher.listName),
- ),
- ),
- ),
+ matcher.inverse
+ ? notInArray(bookmarks.id, listBookmarkIds)
+ : inArray(bookmarks.id, listBookmarkIds),
),
);
}
@@ -373,15 +403,31 @@ async function getIds(
),
);
}
+ case "source": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ matcher.inverse
+ ? or(
+ ne(bookmarks.source, matcher.source),
+ isNull(bookmarks.source),
+ )
+ : eq(bookmarks.source, matcher.source),
+ ),
+ );
+ }
case "and": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return intersect(vals);
}
case "or": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)),
);
return union(vals);
}
@@ -395,7 +441,8 @@ async function getIds(
export async function getBookmarkIdsFromMatcher(
ctx: AuthedContext,
matcher: Matcher,
+ visitedListIds = new Set<string>(),
): Promise<string[]> {
- const results = await getIds(ctx.db, ctx.user.id, matcher);
+ const results = await getIds(ctx, matcher, visitedListIds);
return results.map((r) => r.id);
}
diff --git a/packages/trpc/lib/tracing.ts b/packages/trpc/lib/tracing.ts
new file mode 100644
index 00000000..7b4fb39f
--- /dev/null
+++ b/packages/trpc/lib/tracing.ts
@@ -0,0 +1,63 @@
+import { SpanKind } from "@opentelemetry/api";
+
+import {
+ getTracer,
+ setSpanAttributes,
+ withSpan,
+} from "@karakeep/shared-server";
+import serverConfig from "@karakeep/shared/config";
+
+import type { Context } from "../index";
+
+const tracer = getTracer("@karakeep/trpc");
+
+/**
+ * tRPC middleware that creates a span for each procedure call.
+ * This integrates OpenTelemetry tracing into the tRPC layer.
+ */
+export function createTracingMiddleware() {
+ return async function tracingMiddleware<T>(opts: {
+ ctx: Context;
+ type: "query" | "mutation" | "subscription";
+ path: string;
+ input: unknown;
+ next: () => Promise<T>;
+ }): Promise<T> {
+ // Skip if tracing is disabled
+ if (!serverConfig.tracing.enabled) {
+ return opts.next();
+ }
+
+ const spanName = `trpc.${opts.type}.${opts.path}`;
+
+ return withSpan(
+ tracer,
+ spanName,
+ {
+ kind: SpanKind.SERVER,
+ attributes: {
+ "rpc.system": "trpc",
+ "rpc.method": opts.path,
+ "rpc.type": opts.type,
+ "user.id": opts.ctx.user?.id ?? "anonymous",
+ "user.role": opts.ctx.user?.role ?? "none",
+ },
+ },
+ async () => {
+ return await opts.next();
+ },
+ );
+ };
+}
+
+/**
+ * Helper to add tracing attributes within a tRPC procedure.
+ * Use this to add custom attributes to the current span.
+ */
+export function addTracingAttributes(
+ attributes: Record<string, string | number | boolean>,
+): void {
+ if (serverConfig.tracing.enabled) {
+ setSpanAttributes(attributes);
+ }
+}