diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-01-18 21:38:21 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-18 21:38:21 +0000 |
| commit | e09061bd37c6496685ea0fdabe1d4d01f1b659ad (patch) | |
| tree | 2896ccceb54e7af8dc006d4bdc89b18af42ca078 /packages | |
| parent | edf3f681a77ce6739ca0773af6e5a645e2c91ee9 (diff) | |
| download | karakeep-e09061bd37c6496685ea0fdabe1d4d01f1b659ad.tar.zst | |
feat: Add attachedBy field to update tags endpoint (#2281)
* feat: Add attachedBy field to updateTags endpoint
This change allows callers to specify the attachedBy field when updating
tags on a bookmark. The field defaults to "human" if not provided,
maintaining backward compatibility with existing code.
Changes:
- Added attachedBy field to zManipulatedTagSchema with default "human"
- Updated updateTags endpoint to use the specified attachedBy value
- Created mapping logic to correctly assign attachedBy to each tag
* fix(cli): migrate bookmark source in migration command
* fix
* reduce queries
---------
Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/e2e_tests/tests/api/bookmarks.test.ts | 70 | ||||
| -rw-r--r-- | packages/open-api/karakeep-openapi-spec.json | 16 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 3 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 68 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 54 |
5 files changed, 198 insertions, 13 deletions
diff --git a/packages/e2e_tests/tests/api/bookmarks.test.ts b/packages/e2e_tests/tests/api/bookmarks.test.ts index a6bc85e3..7c14ee61 100644 --- a/packages/e2e_tests/tests/api/bookmarks.test.ts +++ b/packages/e2e_tests/tests/api/bookmarks.test.ts @@ -289,6 +289,76 @@ describe("Bookmarks API", () => { expect(removeTagsRes.status).toBe(200); }); + it("should manage tags with attachedBy field", async () => { + // Create a new bookmark + const { data: createdBookmark, error: createError } = await client.POST( + "/bookmarks", + { + body: { + type: "text", + title: "Test Bookmark for attachedBy", + text: "Testing attachedBy field", + }, + }, + ); + + if (createError) { + console.error("Error creating bookmark:", createError); + throw createError; + } + if (!createdBookmark) { + throw new Error("Bookmark creation failed"); + } + + // Add tags with different attachedBy values + const { data: addTagsResponse, response: addTagsRes } = await client.POST( + "/bookmarks/{bookmarkId}/tags", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + body: { + tags: [ + { tagName: "ai-tag", attachedBy: "ai" }, + { tagName: "human-tag", attachedBy: "human" }, + { tagName: "default-tag" }, // Should default to "human" + ], + }, + }, + ); + + expect(addTagsRes.status).toBe(200); + expect(addTagsResponse!.attached.length).toBe(3); + + // Get the bookmark and verify the attachedBy values + const { data: retrievedBookmark } = await client.GET( + "/bookmarks/{bookmarkId}", + { + params: { + path: { + bookmarkId: createdBookmark.id, + }, + }, + }, + ); + + expect(retrievedBookmark!.tags.length).toBe(3); + + const aiTag = retrievedBookmark!.tags.find((t) => t.name === "ai-tag"); + const humanTag = retrievedBookmark!.tags.find( + (t) => t.name === "human-tag", + ); + const defaultTag = retrievedBookmark!.tags.find( + (t) => t.name === "default-tag", + ); + + expect(aiTag?.attachedBy).toBe("ai"); + expect(humanTag?.attachedBy).toBe("human"); + expect(defaultTag?.attachedBy).toBe("human"); + }); + it("should get lists for a bookmark", async () => { const { data: createdBookmark } = await client.POST("/bookmarks", { body: { diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 52c39be1..a1b9dfc4 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -1445,6 +1445,14 @@ }, "tagName": { "type": "string" + }, + "attachedBy": { + "type": "string", + "enum": [ + "ai", + "human" + ], + "default": "human" } } } @@ -1536,6 +1544,14 @@ }, "tagName": { "type": "string" + }, + "attachedBy": { + "type": "string", + "enum": [ + "ai", + "human" + ], + "default": "human" } } } diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index 1e4f855e..ec27c393 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { zCursorV2 } from "./pagination"; -import { zBookmarkTagSchema } from "./tags"; +import { zAttachedByEnumSchema, zBookmarkTagSchema } from "./tags"; export const MAX_BOOKMARK_TITLE_LENGTH = 1000; @@ -252,6 +252,7 @@ export const zManipulatedTagSchema = z // At least one of the two must be set tagId: z.string().optional(), // If the tag already exists and we know its id we should pass it tagName: z.string().optional(), + attachedBy: zAttachedByEnumSchema.optional().default("human"), }) .refine((val) => !!val.tagId || !!val.tagName, { message: "You must provide either a tagId or a tagName", diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index 7e0e65e0..aaee5447 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -455,6 +455,74 @@ describe("Bookmark Routes", () => { expect(duplicateTagCount).toEqual(1); // Should only be attached once }); + test<CustomTestContext>("updateTags with attachedBy field", async ({ + apiCallers, + }) => { + const api = apiCallers[0].bookmarks; + const bookmark = await api.createBookmark({ + url: "https://bookmark.com", + type: BookmarkTypes.LINK, + }); + + // Test 1: Attach tags with different attachedBy values + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [ + { tagName: "ai-tag", attachedBy: "ai" }, + { tagName: "human-tag", attachedBy: "human" }, + { tagName: "default-tag" }, // Should default to "human" + ], + detach: [], + }); + + let b = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(b.tags.length).toEqual(3); + + const aiTag = b.tags.find((t) => t.name === "ai-tag"); + const humanTag = b.tags.find((t) => t.name === "human-tag"); + const defaultTag = b.tags.find((t) => t.name === "default-tag"); + + expect(aiTag?.attachedBy).toEqual("ai"); + expect(humanTag?.attachedBy).toEqual("human"); + expect(defaultTag?.attachedBy).toEqual("human"); + + // Test 2: Attach existing tag by ID with different attachedBy + // First detach the ai-tag + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagId: aiTag!.id }], + }); + + // Re-attach the same tag but as human + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tagId: aiTag!.id, attachedBy: "human" }], + detach: [], + }); + + b = await api.getBookmark({ bookmarkId: bookmark.id }); + const reAttachedTag = b.tags.find((t) => t.id === aiTag!.id); + expect(reAttachedTag?.attachedBy).toEqual("human"); + + // Test 3: Attach existing tag by name with AI attachedBy + const bookmark2 = await api.createBookmark({ + url: "https://bookmark2.com", + type: BookmarkTypes.LINK, + }); + + await api.updateTags({ + bookmarkId: bookmark2.id, + attach: [{ tagName: "ai-tag", attachedBy: "ai" }], + detach: [], + }); + + const b2 = await api.getBookmark({ bookmarkId: bookmark2.id }); + const aiTagOnB2 = b2.tags.find((t) => t.name === "ai-tag"); + expect(aiTagOnB2?.attachedBy).toEqual("ai"); + expect(aiTagOnB2?.id).toEqual(aiTag!.id); // Should be the same tag + }); + test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; const createdBookmark = await api.createBookmark({ diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index bf960748..37497bcf 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -714,10 +714,10 @@ export const bookmarksAppRouter = router({ ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - // Helper function to fetch tag IDs from a list of tag identifiers - const fetchTagIds = async ( + // Helper function to fetch tag IDs and their names from a list of tag identifiers + const fetchTagIdsWithNames = async ( tagIdentifiers: { tagId?: string; tagName?: string }[], - ): Promise<string[]> => { + ): Promise<{ id: string; name: string }[]> => { const tagIds = tagIdentifiers.flatMap((t) => t.tagId ? [t.tagId] : [], ); @@ -729,7 +729,7 @@ export const bookmarksAppRouter = router({ const [byIds, byNames] = await Promise.all([ tagIds.length > 0 ? ctx.db - .select({ id: bookmarkTags.id }) + .select({ id: bookmarkTags.id, name: bookmarkTags.name }) .from(bookmarkTags) .where( and( @@ -740,7 +740,7 @@ export const bookmarksAppRouter = router({ : Promise.resolve([]), tagNames.length > 0 ? ctx.db - .select({ id: bookmarkTags.id }) + .select({ id: bookmarkTags.id, name: bookmarkTags.name }) .from(bookmarkTags) .where( and( @@ -751,15 +751,25 @@ export const bookmarksAppRouter = router({ : Promise.resolve([]), ]); - // Union results and deduplicate tag IDs - const results = [...byIds, ...byNames]; - return [...new Set(results.map((t) => t.id))]; + // Union results and deduplicate by tag ID + const seen = new Set<string>(); + const results: { id: string; name: string }[] = []; + + for (const tag of [...byIds, ...byNames]) { + if (!seen.has(tag.id)) { + seen.add(tag.id); + results.push({ id: tag.id, name: tag.name }); + } + } + + return results; }; // Normalize tag names and create new tags outside transaction to reduce transaction duration const normalizedAttachTags = input.attach.map((tag) => ({ tagId: tag.tagId, tagName: tag.tagName ? normalizeTagName(tag.tagName) : undefined, + attachedBy: tag.attachedBy, })); { @@ -779,11 +789,31 @@ export const bookmarksAppRouter = router({ } // Fetch tag IDs for attachment/detachment now that we know that they all exist - const [allIdsToAttach, idsToRemove] = await Promise.all([ - fetchTagIds(normalizedAttachTags), - fetchTagIds(input.detach), + const [attachTagsWithNames, detachTagsWithNames] = await Promise.all([ + fetchTagIdsWithNames(normalizedAttachTags), + fetchTagIdsWithNames(input.detach), ]); + // Build the attachedBy map from the fetched results + const tagIdToAttachedBy = new Map<string, "ai" | "human">(); + + for (const fetchedTag of attachTagsWithNames) { + // Find the corresponding input tag + const inputTag = normalizedAttachTags.find( + (t) => + (t.tagId && t.tagId === fetchedTag.id) || + (t.tagName && t.tagName === fetchedTag.name), + ); + + if (inputTag) { + tagIdToAttachedBy.set(fetchedTag.id, inputTag.attachedBy); + } + } + + // Extract just the IDs for the transaction + const allIdsToAttach = attachTagsWithNames.map((t) => t.id); + const idsToRemove = detachTagsWithNames.map((t) => t.id); + const res = await ctx.db.transaction(async (tx) => { // Detaches if (idsToRemove.length > 0) { @@ -805,7 +835,7 @@ export const bookmarksAppRouter = router({ allIdsToAttach.map((i) => ({ tagId: i, bookmarkId: input.bookmarkId, - attachedBy: "human" as const, + attachedBy: tagIdToAttachedBy.get(i) ?? "human", })), ) .onConflictDoNothing(); |
