diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-09-28 11:03:48 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-28 11:03:48 +0100 |
| commit | 62f7d900c52784ff05d933b52379e5455ea6bd00 (patch) | |
| tree | 2702d74c96576447974af84850f3ba6b66beeeb4 /packages/trpc | |
| parent | 9fe09bfa9021c8d85d2d9aef591936101cab19f6 (diff) | |
| download | karakeep-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.ts | 130 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.test.ts | 635 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.ts | 27 |
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, + }); }), }); |
