aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-27 17:40:41 +0000
committerMohamed Bassem <me@mbassem.com>2025-04-27 17:40:41 +0000
commit6178736d64180f9bc8954099c90d54aa2f9f35f5 (patch)
tree02c86e88010d22592258f621f1d2a6b498caeae2 /packages
parent28ca9d5f451b34c6221e9127eaf1d61841fa886c (diff)
downloadkarakeep-6178736d64180f9bc8954099c90d54aa2f9f35f5.tar.zst
fix: Fix smart lists not working in list search qualifiers. Fixes #845
Diffstat (limited to 'packages')
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts69
-rw-r--r--packages/trpc/lib/search.ts110
-rw-r--r--packages/trpc/models/lists.ts23
3 files changed, 133 insertions, 69 deletions
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts
index 9d9b39d7..72b53368 100644
--- a/packages/trpc/lib/__tests__/search.test.ts
+++ b/packages/trpc/lib/__tests__/search.test.ts
@@ -34,6 +34,12 @@ beforeEach(async () => {
email: "test@example.com",
role: "user",
},
+ {
+ id: "another-user",
+ name: "Another User",
+ email: "another@example.com",
+ role: "user",
+ },
]);
// Setup test data
@@ -86,6 +92,14 @@ beforeEach(async () => {
favourited: false,
createdAt: new Date("2024-01-06"),
},
+ {
+ id: "b7",
+ type: BookmarkTypes.ASSET,
+ userId: "another-user",
+ archived: true,
+ favourited: false,
+ createdAt: new Date("2024-01-06"),
+ },
]);
await db.insert(bookmarkLinks).values([
@@ -143,6 +157,21 @@ beforeEach(async () => {
type: "manual",
},
{ id: "l4", userId: testUserId, name: "work", icon: "💼", type: "manual" },
+ {
+ id: "l5",
+ userId: testUserId,
+ name: "smartlist",
+ icon: "🧠",
+ type: "smart",
+ query: "#tag1 or #tag2",
+ },
+ {
+ id: "l6",
+ userId: testUserId,
+ name: "emptylist",
+ icon: "∅",
+ type: "manual",
+ },
]);
await db.insert(bookmarksInLists).values([
@@ -224,6 +253,26 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result).toEqual(["b1", "b6"]);
});
+ it("should handle listName matcher with smartList", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "smartlist",
+ inverse: false,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual(["b1", "b2"]);
+ });
+
+ it("should handle listName matcher with empty list", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "emptylist",
+ inverse: false,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual([]);
+ });
+
it("should handle listName matcher with inverse=true", async () => {
const matcher: Matcher = {
type: "listName",
@@ -234,6 +283,26 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]);
});
+ it("should handle listName matcher with smartList with inverse=true", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "smartlist",
+ inverse: true,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual(["b3", "b4", "b5", "b6"]);
+ });
+
+ it("should handle listName matcher with empty list with inverse=true", async () => {
+ const matcher: Matcher = {
+ type: "listName",
+ listName: "emptylist",
+ inverse: true,
+ };
+ const result = await getBookmarkIdsFromMatcher(mockCtx, matcher);
+ expect(result).toEqual(["b1", "b2", "b3", "b4", "b5", "b6"]);
+ });
+
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/search.ts b/packages/trpc/lib/search.ts
index d4130798..7bb78a01 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -4,21 +4,21 @@ import {
exists,
gt,
gte,
+ inArray,
isNotNull,
like,
lt,
lte,
ne,
notExists,
+ notInArray,
notLike,
} from "drizzle-orm";
import {
bookmarkAssets,
bookmarkLinks,
- bookmarkLists,
bookmarks,
- bookmarksInLists,
bookmarkTags,
rssFeedImportsTable,
rssFeedsTable,
@@ -28,6 +28,7 @@ import { Matcher } from "@karakeep/shared/types/search";
import { toAbsoluteDate } from "@karakeep/shared/utils/relativeDateUtils";
import { AuthedContext } from "..";
+import { List } from "../models/lists";
interface BookmarkQueryReturnType {
id: string;
@@ -87,21 +88,20 @@ function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] {
}
async function getIds(
- db: AuthedContext["db"],
- userId: string,
+ ctx: AuthedContext,
matcher: Matcher,
): Promise<BookmarkQueryReturnType[]> {
switch (matcher.type) {
case "tagName": {
const comp = matcher.inverse ? notExists : exists;
- return db
+ return ctx.db
.selectDistinct({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(
- db
+ ctx.db
.select()
.from(tagsOnBookmarks)
.innerJoin(
@@ -111,7 +111,7 @@ async function getIds(
.where(
and(
eq(tagsOnBookmarks.bookmarkId, bookmarks.id),
- eq(bookmarkTags.userId, userId),
+ eq(bookmarkTags.userId, ctx.user.id),
eq(bookmarkTags.name, matcher.tagName),
),
),
@@ -121,14 +121,14 @@ async function getIds(
}
case "tagged": {
const comp = matcher.tagged ? exists : notExists;
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(
- db
+ ctx.db
.select()
.from(tagsOnBookmarks)
.where(and(eq(tagsOnBookmarks.bookmarkId, bookmarks.id))),
@@ -137,59 +137,43 @@ async function getIds(
);
}
case "listName": {
- const comp = matcher.inverse ? notExists : exists;
- return db
- .selectDistinct({ id: bookmarks.id })
+ const lists = await List.fromName(ctx, matcher.listName);
+ const ids = await Promise.all(lists.map((l) => l.getBookmarkIds()));
+ const comp = matcher.inverse ? notInArray : inArray;
+ return ctx.db
+ .select({ 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),
- ),
- ),
- ),
+ eq(bookmarks.userId, ctx.user.id),
+ comp(bookmarks.id, ids.flat()),
),
);
}
case "inlist": {
- const comp = matcher.inList ? exists : notExists;
- return db
+ const lists = await List.getAll(ctx);
+ const ids = await Promise.all(lists.map((l) => l.getBookmarkIds()));
+ const comp = matcher.inList ? inArray : notInArray;
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
- comp(
- db
- .select()
- .from(bookmarksInLists)
- .where(and(eq(bookmarksInLists.bookmarkId, bookmarks.id))),
- ),
+ eq(bookmarks.userId, ctx.user.id),
+ comp(bookmarks.id, ids.flat()),
),
);
}
case "rssFeedName": {
const comp = matcher.inverse ? notExists : exists;
- return db
+ return ctx.db
.selectDistinct({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(
- db
+ ctx.db
.select()
.from(rssFeedImportsTable)
.innerJoin(
@@ -199,7 +183,7 @@ async function getIds(
.where(
and(
eq(rssFeedImportsTable.bookmarkId, bookmarks.id),
- eq(rssFeedsTable.userId, userId),
+ eq(rssFeedsTable.userId, ctx.user.id),
eq(rssFeedsTable.name, matcher.feedName),
),
),
@@ -208,36 +192,36 @@ async function getIds(
);
}
case "archived": {
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
eq(bookmarks.archived, matcher.archived),
),
);
}
case "url": {
const comp = matcher.inverse ? notLike : like;
- return db
+ return ctx.db
.select({ id: bookmarkLinks.id })
.from(bookmarkLinks)
.leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(bookmarkLinks.url, `%${matcher.url}%`),
),
)
.union(
- db
+ ctx.db
.select({ id: bookmarkAssets.id })
.from(bookmarkAssets)
.leftJoin(bookmarks, eq(bookmarks.id, bookmarkAssets.id))
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
// When a user is asking for a link, the inverse matcher should match only assets with URLs.
isNotNull(bookmarkAssets.sourceUrl),
comp(bookmarkAssets.sourceUrl, `%${matcher.url}%`),
@@ -246,73 +230,73 @@ async function getIds(
);
}
case "favourited": {
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
eq(bookmarks.favourited, matcher.favourited),
),
);
}
case "dateAfter": {
const comp = matcher.inverse ? lt : gte;
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(bookmarks.createdAt, matcher.dateAfter),
),
);
}
case "dateBefore": {
const comp = matcher.inverse ? gt : lte;
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(bookmarks.createdAt, matcher.dateBefore),
),
);
}
case "age": {
const comp = matcher.relativeDate.direction === "newer" ? gte : lt;
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(bookmarks.createdAt, toAbsoluteDate(matcher.relativeDate)),
),
);
}
case "type": {
const comp = matcher.inverse ? ne : eq;
- return db
+ return ctx.db
.select({ id: bookmarks.id })
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, userId),
+ eq(bookmarks.userId, ctx.user.id),
comp(bookmarks.type, matcher.typeName),
),
);
}
case "and": {
const vals = await Promise.all(
- matcher.matchers.map((m) => getIds(db, userId, m)),
+ matcher.matchers.map((m) => getIds(ctx, m)),
);
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)),
);
return union(vals);
}
@@ -327,6 +311,6 @@ export async function getBookmarkIdsFromMatcher(
ctx: AuthedContext,
matcher: Matcher,
): Promise<string[]> {
- const results = await getIds(ctx.db, ctx.user.id, matcher);
+ const results = await getIds(ctx, matcher);
return results.map((r) => r.id);
}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 4da127d2..d278f8d9 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -26,7 +26,7 @@ export abstract class List implements PrivacyAware {
private static fromData(
ctx: AuthedContext,
data: ZBookmarkList & { userId: string },
- ) {
+ ): ManualList | SmartList {
if (data.type === "smart") {
return new SmartList(ctx, data);
} else {
@@ -34,6 +34,21 @@ export abstract class List implements PrivacyAware {
}
}
+ static async fromName(
+ ctx: AuthedContext,
+ name: string,
+ ): Promise<(ManualList | SmartList)[]> {
+ // Names are not unique, so we need to find all lists with the same name
+ const lists = await ctx.db.query.bookmarkLists.findMany({
+ where: and(
+ eq(bookmarkLists.name, name),
+ eq(bookmarkLists.userId, ctx.user.id),
+ ),
+ });
+
+ return lists.map((l) => this.fromData(ctx, l));
+ }
+
static async fromId(
ctx: AuthedContext,
id: string,
@@ -51,11 +66,7 @@ export abstract class List implements PrivacyAware {
message: "List not found",
});
}
- if (list.type === "smart") {
- return new SmartList(ctx, list);
- } else {
- return new ManualList(ctx, list);
- }
+ return this.fromData(ctx, list);
}
static async create(