aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/api/routes/tags.ts23
-rw-r--r--packages/e2e_tests/tests/api/tags.test.ts101
-rw-r--r--packages/open-api/karakeep-openapi-spec.json61
-rw-r--r--packages/open-api/lib/tags.ts6
-rw-r--r--packages/sdk/src/karakeep-api.d.ts9
-rw-r--r--packages/shared-react/hooks/tags.ts35
-rw-r--r--packages/shared-react/hooks/use-debounce.ts17
-rw-r--r--packages/shared/types/tags.ts58
-rw-r--r--packages/shared/utils/switch.ts6
-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
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,
+ });
}),
});