aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-28 11:03:48 +0100
committerGitHub <noreply@github.com>2025-09-28 11:03:48 +0100
commit62f7d900c52784ff05d933b52379e5455ea6bd00 (patch)
tree2702d74c96576447974af84850f3ba6b66beeeb4 /packages/trpc
parent9fe09bfa9021c8d85d2d9aef591936101cab19f6 (diff)
downloadkarakeep-62f7d900c52784ff05d933b52379e5455ea6bd00.tar.zst
feat: Add tag search and pagination (#1987)
* feat: Add tag search and use in the homepage * use paginated query in the all tags view * wire the load more buttons * add skeleton to all tags page * fix attachedby aggregation * fix loading states * fix hasNextPage * use action buttons for load more buttons * migrate the tags auto complete to the search api * Migrate the tags editor to the new search API * Replace tag merging dialog with tag auto completion * Merge both search and list APIs * fix tags.list * add some tests for the endpoint * add relevance based sorting * change cursor * update the REST API * fix review comments * more fixes * fix lockfile * i18n * fix visible tags
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/models/tags.ts130
-rw-r--r--packages/trpc/routers/tags.test.ts635
-rw-r--r--packages/trpc/routers/tags.ts27
3 files changed, 707 insertions, 85 deletions
diff --git a/packages/trpc/models/tags.ts b/packages/trpc/models/tags.ts
index dadb20f7..33b032c1 100644
--- a/packages/trpc/models/tags.ts
+++ b/packages/trpc/models/tags.ts
@@ -1,5 +1,16 @@
import { TRPCError } from "@trpc/server";
-import { and, count, eq, inArray, notExists } from "drizzle-orm";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ gt,
+ inArray,
+ like,
+ notExists,
+ sql,
+} from "drizzle-orm";
import { z } from "zod";
import type { ZAttachedByEnum } from "@karakeep/shared/types/tags";
@@ -12,6 +23,7 @@ import {
zTagBasicSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
+import { switchCase } from "@karakeep/shared/utils/switch";
import { AuthedContext } from "..";
import { PrivacyAware } from "./privacy";
@@ -70,46 +82,100 @@ export class Tag implements PrivacyAware {
}
}
- static async getAllWithStats(ctx: AuthedContext) {
- const tags = await ctx.db
+ static async getAll(
+ ctx: AuthedContext,
+ opts: {
+ nameContains?: string;
+ attachedBy?: "ai" | "human" | "none";
+ sortBy?: "name" | "usage" | "relevance";
+ pagination?: {
+ page: number;
+ limit: number;
+ };
+ } = {},
+ ) {
+ const sortBy = opts.sortBy ?? "usage";
+
+ const countAi = sql<number>`
+ SUM(CASE WHEN ${tagsOnBookmarks.attachedBy} = 'ai' THEN 1 ELSE 0 END)
+ `;
+ const countHuman = sql<number>`
+ SUM(CASE WHEN ${tagsOnBookmarks.attachedBy} = 'human' THEN 1 ELSE 0 END)
+ `;
+ // Count only matched right rows; will be 0 when there are none
+ const countAny = sql<number>`COUNT(${tagsOnBookmarks.tagId})`;
+ let qSql = ctx.db
.select({
id: bookmarkTags.id,
name: bookmarkTags.name,
- attachedBy: tagsOnBookmarks.attachedBy,
- count: count(),
+ countAttachedByAi: countAi.as("countAttachedByAi"),
+ countAttachedByHuman: countHuman.as("countAttachedByHuman"),
+ count: countAny.as("count"),
})
.from(bookmarkTags)
.leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId))
- .where(and(eq(bookmarkTags.userId, ctx.user.id)))
- .groupBy(bookmarkTags.id, tagsOnBookmarks.attachedBy);
+ .where(
+ and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ opts.nameContains
+ ? like(bookmarkTags.name, `%${opts.nameContains}%`)
+ : undefined,
+ ),
+ )
+ .groupBy(bookmarkTags.id, bookmarkTags.name)
+ .orderBy(
+ ...switchCase(sortBy, {
+ name: [asc(bookmarkTags.name)],
+ usage: [desc(sql`count`)],
+ relevance: [
+ desc(sql<number>`
+ CASE
+ WHEN lower(${opts.nameContains ?? ""}) = lower(${bookmarkTags.name}) THEN 2
+ WHEN ${bookmarkTags.name} LIKE ${opts.nameContains ? opts.nameContains + "%" : ""} THEN 1
+ ELSE 0
+ END`),
+ asc(sql<number>`length(${bookmarkTags.name})`),
+ ],
+ }),
+ )
+ .having(
+ opts.attachedBy
+ ? switchCase(opts.attachedBy, {
+ ai: and(eq(countHuman, 0), gt(countAi, 0)),
+ human: gt(countHuman, 0),
+ none: eq(countAny, 0),
+ })
+ : undefined,
+ );
- if (tags.length === 0) {
- return [];
+ if (opts.pagination) {
+ qSql.offset(opts.pagination.page * opts.pagination.limit);
+ qSql.limit(opts.pagination.limit + 1);
}
-
- const tagsById = tags.reduce<
- Record<
- string,
- {
- id: string;
- name: string;
- attachedBy: "ai" | "human" | null;
- count: number;
- }[]
- >
- >((acc, curr) => {
- if (!acc[curr.id]) {
- acc[curr.id] = [];
+ const tags = await qSql;
+
+ let nextCursor = null;
+ if (opts.pagination) {
+ if (tags.length > opts.pagination.limit) {
+ tags.pop();
+ nextCursor = {
+ page: opts.pagination.page + 1,
+ };
}
- acc[curr.id].push(curr);
- return acc;
- }, {});
-
- return Object.entries(tagsById).map(([k, t]) => ({
- id: k,
- name: t[0].name,
- ...Tag._aggregateStats(t),
- }));
+ }
+
+ return {
+ tags: tags.map((t) => ({
+ id: t.id,
+ name: t.name,
+ numBookmarks: t.count,
+ numBookmarksByAttachedType: {
+ ai: t.countAttachedByAi,
+ human: t.countAttachedByHuman,
+ },
+ })),
+ nextCursor,
+ };
}
static async deleteUnused(ctx: AuthedContext): Promise<number> {
diff --git a/packages/trpc/routers/tags.test.ts b/packages/trpc/routers/tags.test.ts
index 4004cc2c..8e557064 100644
--- a/packages/trpc/routers/tags.test.ts
+++ b/packages/trpc/routers/tags.test.ts
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, test } from "vitest";
-import { bookmarkTags } from "@karakeep/db/schema";
+import { bookmarkTags, tagsOnBookmarks } from "@karakeep/db/schema";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
@@ -160,62 +160,605 @@ describe("Tags Routes", () => {
).rejects.toThrow(/Cannot merge tag into itself/);
});
- test<CustomTestContext>("list tags", async ({ apiCallers }) => {
- const api = apiCallers[0].tags;
- await api.create({ name: "tag1" });
- await api.create({ name: "tag2" });
-
- const res = await api.list();
- expect(res.tags.length).toBeGreaterThanOrEqual(2);
- expect(res.tags.some((tag) => tag.name === "tag1")).toBeTruthy();
- expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy();
- });
-
- test<CustomTestContext>("list tags includes bookmark stats", async ({
- apiCallers,
- }) => {
- const tagsApi = apiCallers[0].tags;
- const bookmarksApi = apiCallers[0].bookmarks;
+ describe("list tags", () => {
+ test<CustomTestContext>("basic list", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
- const firstBookmark = await bookmarksApi.createBookmark({
- url: "https://example.com/list-first",
- type: BookmarkTypes.LINK,
- });
- const secondBookmark = await bookmarksApi.createBookmark({
- url: "https://example.com/list-second",
- type: BookmarkTypes.LINK,
+ const res = await api.list();
+ expect(res.tags.length).toBeGreaterThanOrEqual(2);
+ expect(res.tags.some((tag) => tag.name === "tag1")).toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "tag2")).toBeTruthy();
});
- const firstAttachment = await bookmarksApi.updateTags({
- bookmarkId: firstBookmark.id,
- attach: [{ tagName: "list-stats-tag" }],
- detach: [],
+ test<CustomTestContext>("includes bookmark stats", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const firstBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-first",
+ type: BookmarkTypes.LINK,
+ });
+ const secondBookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/list-second",
+ type: BookmarkTypes.LINK,
+ });
+
+ const firstAttachment = await bookmarksApi.updateTags({
+ bookmarkId: firstBookmark.id,
+ attach: [{ tagName: "list-stats-tag" }],
+ detach: [],
+ });
+
+ const tagId = firstAttachment.attached[0];
+
+ await bookmarksApi.updateTags({
+ bookmarkId: secondBookmark.id,
+ attach: [{ tagId }],
+ detach: [],
+ });
+
+ const list = await tagsApi.list();
+ const tagStats = list.tags.find((tag) => tag.id === tagId);
+
+ expect(tagStats).toBeDefined();
+ expect(tagStats!.numBookmarks).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.human).toBe(2);
+ expect(tagStats!.numBookmarksByAttachedType.ai).toBe(0);
});
- const tagId = firstAttachment.attached[0];
+ test<CustomTestContext>("privacy", async ({ apiCallers }) => {
+ const apiUser1 = apiCallers[0].tags;
+ await apiUser1.create({ name: "user1Tag" });
- await bookmarksApi.updateTags({
- bookmarkId: secondBookmark.id,
- attach: [{ tagId }],
- detach: [],
+ const apiUser2 = apiCallers[1].tags; // Different user
+ const resUser2 = await apiUser2.list();
+ expect(resUser2.tags.some((tag) => tag.name === "user1Tag")).toBeFalsy(); // Should not see other user's tags
});
- const list = await tagsApi.list();
- const tagStats = list.tags.find((tag) => tag.id === tagId);
+ test<CustomTestContext>("search by name", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "alpha" });
+ await api.create({ name: "beta" });
+ await api.create({ name: "alph2" });
+
+ {
+ const res = await api.list({ nameContains: "al" });
+ expect(res.tags.length).toBe(2);
+ expect(res.tags.some((tag) => tag.name === "alpha")).toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "beta")).not.toBeTruthy();
+ expect(res.tags.some((tag) => tag.name === "alph2")).toBeTruthy();
+ }
+
+ {
+ const res = await api.list({ nameContains: "beta" });
+ expect(res.tags.length).toBe(1);
+ expect(res.tags.some((tag) => tag.name === "beta")).toBeTruthy();
+ }
+
+ {
+ const res = await api.list({});
+ expect(res.tags.length).toBe(3);
+ }
+ });
- expect(tagStats).toBeDefined();
- expect(tagStats!.numBookmarks).toBe(2);
- expect(tagStats!.numBookmarksByAttachedType.human).toBe(2);
- expect(tagStats!.numBookmarksByAttachedType.ai).toBe(0);
- });
+ describe("pagination", () => {
+ test<CustomTestContext>("basic limit and cursor", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].tags;
+
+ // Create several tags
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+ await api.create({ name: "tag3" });
+ await api.create({ name: "tag4" });
+ await api.create({ name: "tag5" });
+
+ // Test first page with limit
+ const firstPage = await api.list({
+ limit: 2,
+ cursor: { page: 0 },
+ });
+ expect(firstPage.tags.length).toBe(2);
+ expect(firstPage.nextCursor).not.toBeNull();
+
+ // Test second page
+ const secondPage = await api.list({
+ limit: 2,
+ cursor: firstPage.nextCursor!,
+ });
+ expect(secondPage.tags.length).toBe(2);
+ expect(secondPage.nextCursor).not.toBeNull();
+
+ // Test third page (last page)
+ const thirdPage = await api.list({
+ limit: 2,
+ cursor: { page: 2 },
+ });
+ expect(thirdPage.tags.length).toBe(1);
+ expect(thirdPage.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("no limit returns all tags", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+ await api.create({ name: "tag3" });
+
+ const res = await api.list({});
+ expect(res.tags.length).toBe(3);
+ expect(res.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("empty page", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ await api.create({ name: "tag1" });
+
+ const emptyPage = await api.list({
+ limit: 2,
+ cursor: { page: 5 }, // Way beyond available data
+ });
+ expect(emptyPage.tags.length).toBe(0);
+ expect(emptyPage.nextCursor).toBeNull();
+ });
+
+ test<CustomTestContext>("edge cases", async ({ apiCallers }) => {
+ const api = apiCallers[0].tags;
+
+ // Test pagination with no tags
+ const emptyResult = await api.list({
+ limit: 10,
+ cursor: { page: 0 },
+ });
+ expect(emptyResult.tags.length).toBe(0);
+ expect(emptyResult.nextCursor).toBeNull();
+
+ // Create exactly one page worth of tags
+ await api.create({ name: "tag1" });
+ await api.create({ name: "tag2" });
+
+ const exactPage = await api.list({
+ limit: 2,
+ cursor: { page: 0 },
+ });
+ expect(exactPage.tags.length).toBe(2);
+ expect(exactPage.nextCursor).toBeNull();
+
+ // Test with limit larger than available tags
+ const oversizedLimit = await api.list({
+ limit: 100,
+ cursor: { page: 0 },
+ });
+ expect(oversizedLimit.tags.length).toBe(2);
+ expect(oversizedLimit.nextCursor).toBeNull();
+ });
+ });
- test<CustomTestContext>("list tags - privacy", async ({ apiCallers }) => {
- const apiUser1 = apiCallers[0].tags;
- await apiUser1.create({ name: "user1Tag" });
+ describe("attachedBy filtering", () => {
+ test<CustomTestContext>("human tags", async ({ apiCallers, db }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create tags attached by humans
+ const bookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/human",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "human-tag" }],
+ detach: [],
+ });
+
+ // Create an unused tag (no attachments)
+ await tagsApi.create({ name: "unused-tag" });
+
+ const aiTag = await tagsApi.create({ name: "ai-tag" });
+ await db.insert(tagsOnBookmarks).values([
+ {
+ bookmarkId: bookmark.id,
+ tagId: aiTag.id,
+ attachedBy: "ai",
+ },
+ ]);
+
+ const humanTags = await tagsApi.list({ attachedBy: "human" });
+ expect(humanTags.tags.length).toBe(1);
+ expect(humanTags.tags[0].name).toBe("human-tag");
+ });
+
+ test<CustomTestContext>("none (unused tags)", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create a used tag
+ const bookmark = await bookmarksApi.createBookmark({
+ url: "https://example.com/used",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagName: "used-tag" }],
+ detach: [],
+ });
+
+ // Create unused tags
+ await tagsApi.create({ name: "unused-tag-1" });
+ await tagsApi.create({ name: "unused-tag-2" });
+
+ const unusedTags = await tagsApi.list({ attachedBy: "none" });
+ expect(unusedTags.tags.length).toBe(2);
+
+ const tagNames = unusedTags.tags.map((tag) => tag.name);
+ expect(tagNames).toContain("unused-tag-1");
+ expect(tagNames).toContain("unused-tag-2");
+ expect(tagNames).not.toContain("used-tag");
+ });
+
+ test<CustomTestContext>("ai tags", async ({ apiCallers, db }) => {
+ const bookmarksApi = apiCallers[0].bookmarks;
+ const tagsApi = apiCallers[0].tags;
+
+ const tag1 = await tagsApi.create({ name: "ai-tag" });
+ const tag2 = await tagsApi.create({ name: "human-tag" });
+
+ // Create bookmarks and attach tags to give them usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/z",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Manually attach some tags
+ await db.insert(tagsOnBookmarks).values([
+ {
+ bookmarkId: bookmark1.id,
+ tagId: tag1.id,
+ attachedBy: "ai",
+ },
+ {
+ bookmarkId: bookmark1.id,
+ tagId: tag2.id,
+ attachedBy: "human",
+ },
+ ]);
+
+ const aiTags = await tagsApi.list({ attachedBy: "ai" });
+ expect(aiTags.tags.length).toBe(1);
+ expect(aiTags.tags[0].name).toBe("ai-tag");
+ });
+ });
- const apiUser2 = apiCallers[1].tags; // Different user
- const resUser2 = await apiUser2.list();
- expect(resUser2.tags.some((tag) => tag.name === "user1Tag")).toBeFalsy(); // Should not see other user's tags
+ describe("sortBy", () => {
+ test<CustomTestContext>("name sorting", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks and attach tags to give them usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/z",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/a",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/m",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Attach tags in order: zebra (1 use), apple (2 uses), middle (1 use)
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "zebra" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "apple" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [{ tagName: "apple" }, { tagName: "middle" }],
+ detach: [],
+ });
+
+ // Test sorting by name (alphabetical)
+ const nameSort = await tagsApi.list({ sortBy: "name" });
+ expect(nameSort.tags.length).toBe(3);
+ expect(nameSort.tags[0].name).toBe("apple");
+ expect(nameSort.tags[1].name).toBe("middle");
+ expect(nameSort.tags[2].name).toBe("zebra");
+ });
+
+ test<CustomTestContext>("usage sorting (default)", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks and attach tags with different usage counts
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage2",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/usage3",
+ type: BookmarkTypes.LINK,
+ });
+
+ // single-use: 1 bookmark, high-use: 3 bookmarks, medium-use: 2 bookmarks
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "high-use" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "high-use" }, { tagName: "medium-use" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [
+ { tagName: "high-use" },
+ { tagName: "medium-use" },
+ { tagName: "single-use" },
+ ],
+ detach: [],
+ });
+
+ // Test default sorting (usage) and explicit usage sorting
+ const defaultSort = await tagsApi.list({});
+ expect(defaultSort.tags.length).toBe(3);
+ expect(defaultSort.tags[0].name).toBe("high-use");
+ expect(defaultSort.tags[0].numBookmarks).toBe(3);
+ expect(defaultSort.tags[1].name).toBe("medium-use");
+ expect(defaultSort.tags[1].numBookmarks).toBe(2);
+ expect(defaultSort.tags[2].name).toBe("single-use");
+ expect(defaultSort.tags[2].numBookmarks).toBe(1);
+
+ const usageSort = await tagsApi.list({ sortBy: "usage" });
+ expect(usageSort.tags[0].name).toBe("high-use");
+ expect(usageSort.tags[1].name).toBe("medium-use");
+ expect(usageSort.tags[2].name).toBe("single-use");
+ });
+
+ test<CustomTestContext>("relevance sorting", async ({ apiCallers }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks to give tags usage
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/rel1",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create tags with different relevance to search term "java"
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [
+ { tagName: "java" }, // Exact match - highest relevance
+ { tagName: "javascript" }, // Prefix match
+ { tagName: "java-script" }, // Prefix match (shorter)
+ { tagName: "advanced-java" }, // Substring match
+ ],
+ detach: [],
+ });
+
+ // Test relevance sorting
+ const relevanceSort = await tagsApi.list({
+ nameContains: "java",
+ sortBy: "relevance",
+ });
+
+ expect(relevanceSort.tags.length).toBe(4);
+
+ // Exact match should be first
+ expect(relevanceSort.tags[0].name).toBe("java");
+
+ // Prefix matches should come next, shorter first (by length)
+ expect(relevanceSort.tags[1].name).toBe("javascript"); // length 10
+ expect(relevanceSort.tags[2].name).toBe("java-script"); // length 11
+
+ // Substring matches should be last
+ expect(relevanceSort.tags[3].name).toBe("advanced-java");
+ });
+
+ test<CustomTestContext>("relevance sorting case insensitive", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/case",
+ type: BookmarkTypes.LINK,
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [
+ { tagName: "React" }, // Exact match (different case)
+ { tagName: "ReactJS" }, // Prefix match
+ { tagName: "my-react" }, // Substring match
+ ],
+ detach: [],
+ });
+
+ const relevanceSort = await tagsApi.list({
+ nameContains: "react",
+ sortBy: "relevance",
+ });
+
+ expect(relevanceSort.tags.length).toBe(3);
+ expect(relevanceSort.tags[0].name).toBe("React"); // Exact match first
+ expect(relevanceSort.tags[1].name).toBe("ReactJS"); // Prefix match second
+ expect(relevanceSort.tags[2].name).toBe("my-react"); // Substring match last
+ });
+
+ test<CustomTestContext>("relevance sorting without search term is prevented by validation", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+
+ // Without nameContains, relevance sorting should throw validation error
+ await expect(() =>
+ tagsApi.list({ sortBy: "relevance" }),
+ ).rejects.toThrow(/Relevance sorting requires a nameContains filter/);
+ });
+ });
+
+ describe("combination filtering", () => {
+ test<CustomTestContext>("nameContains with attachedBy", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create bookmarks with tags
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/combo1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/combo2",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Attach human tags with "test" in name
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "test-human" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "test-used" }],
+ detach: [],
+ });
+
+ // Create unused tag with "test" in name
+ await tagsApi.create({ name: "test-unused" });
+
+ // Create used tag without "test" in name
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "other-human" }],
+ detach: [],
+ });
+
+ // Test combination: nameContains + attachedBy human
+ const humanTestTags = await tagsApi.list({
+ nameContains: "test",
+ attachedBy: "human",
+ });
+ expect(humanTestTags.tags.length).toBe(2);
+
+ const humanTestNames = humanTestTags.tags.map((tag) => tag.name);
+ expect(humanTestNames).toContain("test-human");
+ expect(humanTestNames).toContain("test-used");
+ expect(humanTestNames).not.toContain("test-unused");
+ expect(humanTestNames).not.toContain("other-human");
+
+ // Test combination: nameContains + attachedBy none
+ const unusedTestTags = await tagsApi.list({
+ nameContains: "test",
+ attachedBy: "none",
+ });
+ expect(unusedTestTags.tags.length).toBe(1);
+ expect(unusedTestTags.tags[0].name).toBe("test-unused");
+ });
+
+ test<CustomTestContext>("all parameters together", async ({
+ apiCallers,
+ }) => {
+ const tagsApi = apiCallers[0].tags;
+ const bookmarksApi = apiCallers[0].bookmarks;
+
+ // Create multiple bookmarks with various tags
+ const bookmark1 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all1",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark2 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all2",
+ type: BookmarkTypes.LINK,
+ });
+ const bookmark3 = await bookmarksApi.createBookmark({
+ url: "https://example.com/all3",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create tags with different usage patterns
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark1.id,
+ attach: [{ tagName: "filter-high" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "filter-high" }, { tagName: "filter-low" }],
+ detach: [],
+ });
+
+ await bookmarksApi.updateTags({
+ bookmarkId: bookmark3.id,
+ attach: [{ tagName: "filter-high" }],
+ detach: [],
+ });
+
+ // Test all parameters: nameContains + attachedBy + sortBy + pagination
+ const result = await tagsApi.list({
+ nameContains: "filter",
+ attachedBy: "human",
+ sortBy: "usage",
+ limit: 1,
+ cursor: { page: 0 },
+ });
+
+ expect(result.tags.length).toBe(1);
+ expect(result.tags[0].name).toBe("filter-high"); // Highest usage
+ expect(result.tags[0].numBookmarks).toBe(3);
+ expect(result.nextCursor).not.toBeNull();
+
+ // Get second page
+ const secondPage = await tagsApi.list({
+ nameContains: "filter",
+ attachedBy: "human",
+ sortBy: "usage",
+ limit: 1,
+ cursor: result.nextCursor!,
+ });
+
+ expect(secondPage.tags.length).toBe(1);
+ expect(secondPage.tags[0].name).toBe("filter-low"); // Lower usage
+ expect(secondPage.tags[0].numBookmarks).toBe(1);
+ expect(secondPage.nextCursor).toBeNull();
+ });
+ });
});
test<CustomTestContext>("create strips extra leading hashes", async ({
diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts
index c1217cf9..d4cfbe8c 100644
--- a/packages/trpc/routers/tags.ts
+++ b/packages/trpc/routers/tags.ts
@@ -5,6 +5,8 @@ import {
zCreateTagRequestSchema,
zGetTagResponseSchema,
zTagBasicSchema,
+ zTagListResponseSchema,
+ zTagListValidatedRequestSchema,
zUpdateTagRequestSchema,
} from "@karakeep/shared/types/tags";
@@ -90,13 +92,24 @@ export const tagsAppRouter = router({
return await Tag.merge(ctx, input);
}),
list: authedProcedure
- .output(
- z.object({
- tags: z.array(zGetTagResponseSchema),
- }),
+ .input(
+ // TODO: Remove the optional and default once the next release is out.
+ zTagListValidatedRequestSchema
+ .optional()
+ .default(zTagListValidatedRequestSchema.parse({})),
)
- .query(async ({ ctx }) => {
- const tags = await Tag.getAllWithStats(ctx);
- return { tags };
+ .output(zTagListResponseSchema)
+ .query(async ({ ctx, input }) => {
+ return await Tag.getAll(ctx, {
+ nameContains: input.nameContains,
+ attachedBy: input.attachedBy,
+ sortBy: input.sortBy,
+ pagination: input.limit
+ ? {
+ page: input.cursor?.page ?? 0,
+ limit: input.limit,
+ }
+ : undefined,
+ });
}),
});