diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/routes/tags.ts | 23 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/tags.test.ts | 101 | ||||
| -rw-r--r-- | packages/open-api/karakeep-openapi-spec.json | 61 | ||||
| -rw-r--r-- | packages/open-api/lib/tags.ts | 6 | ||||
| -rw-r--r-- | packages/sdk/src/karakeep-api.d.ts | 9 | ||||
| -rw-r--r-- | packages/shared-react/hooks/tags.ts | 35 | ||||
| -rw-r--r-- | packages/shared-react/hooks/use-debounce.ts | 17 | ||||
| -rw-r--r-- | packages/shared/types/tags.ts | 58 | ||||
| -rw-r--r-- | packages/shared/utils/switch.ts | 6 | ||||
| -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 |
12 files changed, 1017 insertions, 91 deletions
diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts index 816e58b4..79e36e99 100644 --- a/packages/api/routes/tags.ts +++ b/packages/api/routes/tags.ts @@ -1,8 +1,11 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; +import { z } from "zod"; import { zCreateTagRequestSchema, + zTagListApiResultSchema, + zTagListQueryParamsSchema, zUpdateTagRequestSchema, } from "@karakeep/shared/types/tags"; @@ -14,9 +17,23 @@ const app = new Hono() .use(authMiddleware) // GET /tags - .get("/", async (c) => { - const tags = await c.var.api.tags.list(); - return c.json(tags, 200); + .get("/", zValidator("query", zTagListQueryParamsSchema), async (c) => { + const searchParams = c.req.valid("query"); + const tags = await c.var.api.tags.list({ + nameContains: searchParams.nameContains, + attachedBy: searchParams.attachedBy, + sortBy: searchParams.sort, + cursor: searchParams.cursor, + limit: searchParams.limit, + }); + + const resp: z.infer<typeof zTagListApiResultSchema> = { + tags: tags.tags, + nextCursor: tags.nextCursor + ? Buffer.from(JSON.stringify(tags.nextCursor)).toString("base64url") + : null, + }; + return c.json(resp, 200); }) // POST /tags diff --git a/packages/e2e_tests/tests/api/tags.test.ts b/packages/e2e_tests/tests/api/tags.test.ts index 6c387628..bfedb307 100644 --- a/packages/e2e_tests/tests/api/tags.test.ts +++ b/packages/e2e_tests/tests/api/tags.test.ts @@ -198,4 +198,105 @@ describe("Tags API", () => { expect(updatedTaggedBookmarks!.bookmarks.length).toBe(1); expect(updatedTaggedBookmarks!.bookmarks[0].id).toBe(secondBookmark!.id); }); + + it("should paginate through tags", async () => { + // Create multiple tags + const tagNames = ["Tag A", "Tag B", "Tag C", "Tag D", "Tag E"]; + const createdTags = []; + + for (const name of tagNames) { + const { data: tag } = await client.POST("/tags", { + body: { name }, + }); + createdTags.push(tag!); + } + + // Test pagination with limit of 2 + const { data: firstPage, response: firstResponse } = await client.GET( + "/tags", + { + params: { + query: { + limit: 2, + }, + }, + }, + ); + + expect(firstResponse.status).toBe(200); + expect(firstPage!.tags.length).toBe(2); + expect(firstPage!.nextCursor).toBeDefined(); + + // Get second page using cursor + const { data: secondPage, response: secondResponse } = await client.GET( + "/tags", + { + params: { + query: { + limit: 2, + cursor: firstPage!.nextCursor!, + }, + }, + }, + ); + + expect(secondResponse.status).toBe(200); + expect(secondPage!.tags.length).toBe(2); + expect(secondPage!.nextCursor).toBeDefined(); + + // Get third page + const { data: thirdPage, response: thirdResponse } = await client.GET( + "/tags", + { + params: { + query: { + limit: 2, + cursor: secondPage!.nextCursor!, + }, + }, + }, + ); + + expect(thirdResponse.status).toBe(200); + expect(thirdPage!.tags.length).toBe(1); // Only one tag remaining + expect(thirdPage!.nextCursor).toBeNull(); // No more pages + + // Verify all tags are accounted for across pages + const allPagedTags = [ + ...firstPage!.tags, + ...secondPage!.tags, + ...thirdPage!.tags, + ]; + expect(allPagedTags.length).toBe(5); + + // Verify all created tags are included + const allPagedTagIds = allPagedTags.map((tag) => tag.id); + const createdTagIds = createdTags.map((tag) => tag.id); + expect(allPagedTagIds.sort()).toEqual(createdTagIds.sort()); + }); + + it("Invalid cursor should return 400", async () => { + const { response } = await client.GET("/tags", { + params: { + query: { + limit: 2, + cursor: "{}", + }, + }, + }); + expect(response.status).toBe(400); + }); + + it("Listing without args returns all tags", async () => { + const tagNames = ["Tag A", "Tag B", "Tag C", "Tag D", "Tag E"]; + + for (const name of tagNames) { + await client.POST("/tags", { + body: { name }, + }); + } + + const { data } = await client.GET("/tags"); + expect(data?.tags).toHaveLength(tagNames.length); + }); }); diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 83a5b811..ffa9c357 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -2292,6 +2292,60 @@ "bearerAuth": [] } ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "nameContains", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "name", + "usage", + "relevance" + ], + "default": "usage" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "ai", + "human", + "none" + ] + }, + "required": false, + "name": "attachedBy", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + } + ], "responses": { "200": { "description": "Object with all tags data.", @@ -2305,10 +2359,15 @@ "items": { "$ref": "#/components/schemas/Tag" } + }, + "nextCursor": { + "type": "string", + "nullable": true } }, "required": [ - "tags" + "tags", + "nextCursor" ] } } diff --git a/packages/open-api/lib/tags.ts b/packages/open-api/lib/tags.ts index 0a4f62cb..84af39b1 100644 --- a/packages/open-api/lib/tags.ts +++ b/packages/open-api/lib/tags.ts @@ -9,6 +9,7 @@ import { zCreateTagRequestSchema, zGetTagResponseSchema, zTagBasicSchema, + zTagListQueryParamsSchema, zUpdateTagRequestSchema, } from "@karakeep/shared/types/tags"; @@ -43,7 +44,9 @@ registry.registerPath({ summary: "Get all tags", tags: ["Tags"], security: [{ [BearerAuth.name]: [] }], - request: {}, + request: { + query: zTagListQueryParamsSchema, + }, responses: { 200: { description: "Object with all tags data.", @@ -51,6 +54,7 @@ registry.registerPath({ "application/json": { schema: z.object({ tags: z.array(TagSchema), + nextCursor: z.string().nullable(), }), }, }, diff --git a/packages/sdk/src/karakeep-api.d.ts b/packages/sdk/src/karakeep-api.d.ts index a50fec82..1ac35e04 100644 --- a/packages/sdk/src/karakeep-api.d.ts +++ b/packages/sdk/src/karakeep-api.d.ts @@ -1146,7 +1146,13 @@ export interface paths { */ get: { parameters: { - query?: never; + query?: { + nameContains?: string; + sort?: "name" | "usage" | "relevance"; + attachedBy?: "ai" | "human" | "none"; + cursor?: string; + limit?: number | null; + }; header?: never; path?: never; cookie?: never; @@ -1161,6 +1167,7 @@ export interface paths { content: { "application/json": { tags: components["schemas"]["Tag"][]; + nextCursor: string | null; }; }; }; diff --git a/packages/shared-react/hooks/tags.ts b/packages/shared-react/hooks/tags.ts index bbbe3d0e..f02ebc8f 100644 --- a/packages/shared-react/hooks/tags.ts +++ b/packages/shared-react/hooks/tags.ts @@ -1,5 +1,40 @@ +import { keepPreviousData } from "@tanstack/react-query"; + +import { ZTagListResponse } from "@karakeep/shared/types/tags"; + import { api } from "../trpc"; +export function usePaginatedSearchTags( + input: Parameters<typeof api.tags.list.useInfiniteQuery>[0], +) { + return api.tags.list.useInfiniteQuery(input, { + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => lastPage.nextCursor, + select: (data) => ({ + tags: data.pages.flatMap((page) => page.tags), + }), + gcTime: 60_000, + }); +} + +export function useTagAutocomplete<T>(opts: { + nameContains: string; + select?: (tags: ZTagListResponse) => T; +}) { + return api.tags.list.useQuery( + { + nameContains: opts.nameContains, + limit: 50, + sortBy: opts.nameContains ? "relevance" : "usage", + }, + { + select: opts.select, + placeholderData: keepPreviousData, + gcTime: opts.nameContains?.length > 0 ? 60_000 : 3_600_000, + }, + ); +} + export function useCreateTag( ...opts: Parameters<typeof api.tags.create.useMutation> ) { diff --git a/packages/shared-react/hooks/use-debounce.ts b/packages/shared-react/hooks/use-debounce.ts new file mode 100644 index 00000000..a973d774 --- /dev/null +++ b/packages/shared-react/hooks/use-debounce.ts @@ -0,0 +1,17 @@ +import React from "react"; + +export function useDebounce<T>(value: T, delayMs: number): T { + const [debouncedValue, setDebouncedValue] = React.useState<T>(value); + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => { + clearTimeout(handler); + }; + }, [value, delayMs]); + + return debouncedValue; +} diff --git a/packages/shared/types/tags.ts b/packages/shared/types/tags.ts index efb26bfa..91ad1d96 100644 --- a/packages/shared/types/tags.ts +++ b/packages/shared/types/tags.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { normalizeTagName } from "../utils/tag"; +export const MAX_NUM_TAGS_PER_PAGE = 1000; + const zTagNameSchemaWithValidation = z .string() .transform((s) => normalizeTagName(s).trim()) @@ -38,3 +40,59 @@ export const zTagBasicSchema = z.object({ name: z.string(), }); export type ZTagBasic = z.infer<typeof zTagBasicSchema>; + +export const zTagCursorSchema = z.object({ + page: z.number().int().min(0), +}); + +export const zTagListRequestSchema = z.object({ + nameContains: z.string().optional(), + attachedBy: z.enum([...zAttachedByEnumSchema.options, "none"]).optional(), + sortBy: z.enum(["name", "usage", "relevance"]).optional().default("usage"), + cursor: zTagCursorSchema.nullish().default({ page: 0 }), + // TODO: Remove the optional to enforce a limit after the next release + limit: z.number().int().min(1).max(MAX_NUM_TAGS_PER_PAGE).optional(), +}); + +export const zTagListValidatedRequestSchema = zTagListRequestSchema.refine( + (val) => val.sortBy != "relevance" || val.nameContains !== undefined, + { + message: "Relevance sorting requires a nameContains filter", + path: ["sortBy"], + }, +); + +export const zTagListResponseSchema = z.object({ + tags: z.array(zGetTagResponseSchema), + nextCursor: zTagCursorSchema.nullish(), +}); +export type ZTagListResponse = z.infer<typeof zTagListResponseSchema>; + +// API Types + +export const zTagListQueryParamsSchema = z.object({ + nameContains: zTagListRequestSchema.shape.nameContains, + sort: zTagListRequestSchema.shape.sortBy, + attachedBy: zTagListRequestSchema.shape.attachedBy, + cursor: z + .string() + .transform((val, ctx) => { + try { + return JSON.parse(Buffer.from(val, "base64url").toString("utf8")); + } catch { + ctx.addIssue({ + code: "custom", + message: "Invalid cursor", + }); + return z.NEVER; + } + }) + .optional() + .pipe(zTagListRequestSchema.shape.cursor), + limit: z.coerce.number().optional(), +}); + +export const zTagListApiResultSchema = z.object({ + tags: zTagListResponseSchema.shape.tags, + nextCursor: z.string().nullish(), +}); diff --git a/packages/shared/utils/switch.ts b/packages/shared/utils/switch.ts new file mode 100644 index 00000000..9123c060 --- /dev/null +++ b/packages/shared/utils/switch.ts @@ -0,0 +1,6 @@ +export function switchCase<T extends string | number, R>( + value: T, + cases: Record<T, R>, +) { + return cases[value]; +} 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, + }); }), }); |
